From d9d76cf2d266ca992d40dd2c26ecf8414d1deed3 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Tue, 27 Jan 2026 23:58:18 +0800 Subject: [PATCH 01/64] =?UTF-8?q?feat(auth):=20multi-protocol=20auth=20sta?= =?UTF-8?q?ge=201=20=E2=80=94=20models,=20protocol=20interfaces,=20WWW-Aut?= =?UTF-8?q?henticate=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/client/auth/utils.py | 74 ++++++++++++++++++- src/mcp/server/auth/middleware/bearer_auth.py | 20 +++++ src/mcp/shared/auth.py | 22 ++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 1aa960b9c..7bcea8c34 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -16,9 +16,17 @@ from mcp.types import LATEST_PROTOCOL_VERSION -def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: +def extract_field_from_www_auth(response: Response, field_name: str, auth_scheme: str | None = None) -> str | None: """Extract field from WWW-Authenticate header. + Supports multiple authentication schemes (Bearer, ApiKey, MutualTLS, etc.). + If auth_scheme is provided, only searches within that scheme's parameters. + + Args: + response: HTTP response containing WWW-Authenticate header + field_name: Name of the field to extract + auth_scheme: Optional authentication scheme to search within (e.g., "Bearer", "ApiKey") + Returns: Field value if found in WWW-Authenticate header, None otherwise """ @@ -26,9 +34,22 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None + # If auth_scheme is specified, extract only from that scheme's parameters + if auth_scheme: + # Pattern to match the specified auth scheme and its parameters + scheme_pattern = rf'{re.escape(auth_scheme)}\s+([^,]+(?:,\s*[^,]+)*)' + scheme_match = re.search(scheme_pattern, www_auth_header, re.IGNORECASE) + if not scheme_match: + return None + # Search within the matched scheme's parameters + search_text = scheme_match.group(1) + else: + # Search in the entire header (backward compatible) + search_text = www_auth_header + # Pattern matches: field_name="value" or field_name=value (unquoted) pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' - match = re.search(pattern, www_auth_header) + match = re.search(pattern, search_text) if match: # Return quoted value if present, otherwise unquoted value @@ -58,6 +79,55 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None: return extract_field_from_www_auth(response, "resource_metadata") +def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: + """ + Extract auth_protocols field from WWW-Authenticate header (MCP extension). + + Returns: + List of protocol IDs if found in WWW-Authenticate header, None otherwise + """ + protocols_str = extract_field_from_www_auth(response, "auth_protocols") + if not protocols_str: + return None + return protocols_str.split() + + +def extract_default_protocol_from_www_auth(response: Response) -> str | None: + """ + Extract default_protocol field from WWW-Authenticate header (MCP extension). + + Returns: + Default protocol ID if found in WWW-Authenticate header, None otherwise + """ + return extract_field_from_www_auth(response, "default_protocol") + + +def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: + """ + Extract protocol_preferences field from WWW-Authenticate header (MCP extension). + + Format: "protocol1:priority1,protocol2:priority2" + + Returns: + Dictionary mapping protocol IDs to priorities if found, None otherwise + """ + prefs_str = extract_field_from_www_auth(response, "protocol_preferences") + if not prefs_str: + return None + preferences: dict[str, int] = {} + for item in prefs_str.split(","): + parts = item.split(":") + if len(parts) == 2: + proto = parts[0].strip() + try: + priority = int(parts[1].strip()) + preferences[proto] = priority + except ValueError: + # Skip invalid entries + continue + return preferences if preferences else None + + def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: """Build ordered list of URLs to try for protected resource metadata discovery. diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 6825c00b9..4a95b0b44 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -59,6 +59,9 @@ def __init__( app: Any, required_scopes: list[str], resource_metadata_url: AnyHttpUrl | None = None, + auth_protocols: list[str] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, ): """Initialize the middleware. @@ -66,10 +69,16 @@ def __init__( app: ASGI application required_scopes: List of scopes that the token must have resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header + auth_protocols: List of supported authentication protocol IDs (MCP extension) + default_protocol: Default authentication protocol ID (MCP extension) + protocol_preferences: Dictionary mapping protocol IDs to priority values (MCP extension) """ self.app = app self.required_scopes = required_scopes self.resource_metadata_url = resource_metadata_url + self.auth_protocols = auth_protocols + self.default_protocol = default_protocol + self.protocol_preferences = protocol_preferences async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: auth_user = scope.get("user") @@ -98,6 +107,17 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr if self.resource_metadata_url: # pragma: no cover www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') + # Add protocol-related fields (MCP extension) + if self.auth_protocols: + protocols_str = " ".join(self.auth_protocols) + www_auth_parts.append(f'auth_protocols="{protocols_str}"') + if self.default_protocol: + www_auth_parts.append(f'default_protocol="{self.default_protocol}"') + if self.protocol_preferences: + prefs_str = ",".join(f"{proto}:{priority}" for proto, priority in self.protocol_preferences.items()) + www_auth_parts.append(f'protocol_preferences="{prefs_str}"') + + # Keep scheme as Bearer for backwards compatibility. www_authenticate = f"Bearer {', '.join(www_auth_parts)}" # Send response diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index bf03a8b8d..67692004a 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -130,6 +130,24 @@ class OAuthMetadata(BaseModel): client_id_metadata_document_supported: bool | None = None +class AuthProtocolMetadata(BaseModel): + """单个授权协议的元数据(MCP扩展)""" + + protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") + protocol_version: str + metadata_url: AnyHttpUrl | None = None + endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) + capabilities: list[str] = Field(default_factory=list) + # OAuth特定字段(可选) + client_auth_methods: list[str] | None = None + grant_types: list[str] | None = None + scopes_supported: list[str] | None = None + # DPoP支持(协议无关) + dpop_signing_alg_values_supported: list[str] | None = None + dpop_bound_credentials_required: bool | None = None + additional_params: dict[str, Any] = Field(default_factory=dict) + + class ProtectedResourceMetadata(BaseModel): """RFC 9728 OAuth 2.0 Protected Resource Metadata. See https://datatracker.ietf.org/doc/html/rfc9728#section-2 @@ -151,3 +169,7 @@ class ProtectedResourceMetadata(BaseModel): dpop_signing_alg_values_supported: list[str] | None = None # dpop_bound_access_tokens_required default is False, but ommited here for clarity dpop_bound_access_tokens_required: bool | None = None + # MCP扩展字段(多协议支持) + mcp_auth_protocols: list["AuthProtocolMetadata"] | None = None + mcp_default_auth_protocol: str | None = None + mcp_auth_protocol_preferences: dict[str, int] | None = None \ No newline at end of file From 1334eef2c785bc093763a0ff0e5021fbeff927d7 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 22:32:03 +0800 Subject: [PATCH 02/64] Add AuthProtocolRegistry for multi-protocol auth selection --- src/mcp/client/auth/registry.py | 87 +++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/mcp/client/auth/registry.py diff --git a/src/mcp/client/auth/registry.py b/src/mcp/client/auth/registry.py new file mode 100644 index 000000000..e3dc6d185 --- /dev/null +++ b/src/mcp/client/auth/registry.py @@ -0,0 +1,87 @@ +""" +协议注册表。 + +提供多协议授权实现的注册与选择逻辑。 +""" + +from mcp.client.auth.protocol import AuthProtocol + + +class AuthProtocolRegistry: + """ + 授权协议注册表。 + + 用于注册和获取协议实现类,并根据服务器声明的可用协议、默认协议及优先级选择协议。 + """ + + _protocols: dict[str, type[AuthProtocol]] = {} + + @classmethod + def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]) -> None: + """ + 注册协议实现。 + + Args: + protocol_id: 协议标识(如 oauth2、api_key) + protocol_class: 实现 AuthProtocol 的类(非实例) + """ + cls._protocols[protocol_id] = protocol_class + + @classmethod + def get_protocol_class(cls, protocol_id: str) -> type[AuthProtocol] | None: + """ + 获取协议实现类。 + + Args: + protocol_id: 协议标识 + + Returns: + 协议类,未注册时返回 None + """ + return cls._protocols.get(protocol_id) + + @classmethod + def select_protocol( + cls, + available_protocols: list[str], + default_protocol: str | None = None, + preferences: dict[str, int] | None = None, + ) -> str | None: + """ + 从服务器声明的可用协议中选出一个客户端支持的协议。 + + 选择顺序: + 1. 过滤出客户端已注册的协议 + 2. 若存在默认协议且客户端支持,则优先返回默认协议 + 3. 若有优先级映射,按优先级数值升序排序后取第一个 + 4. 否则返回第一个支持的协议 + + Args: + available_protocols: 服务器声明的可用协议 ID 列表 + default_protocol: 服务器推荐的默认协议 ID(可选) + preferences: 协议优先级映射,数值越小优先级越高(可选) + + Returns: + 选中的协议 ID,若无交集则返回 None + """ + supported = [p for p in available_protocols if p in cls._protocols] + if not supported: + return None + + if default_protocol and default_protocol in supported: + return default_protocol + + if preferences: + supported.sort(key=lambda p: preferences.get(p, 999)) + + return supported[0] if supported else None + + @classmethod + def list_registered(cls) -> list[str]: + """ + 返回已注册的协议 ID 列表(便于测试或调试)。 + + Returns: + 已注册的 protocol_id 列表 + """ + return list(cls._protocols.keys()) From d49e295b4c8c3997f4436ea15d6c34d6b1aedeb6 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 22:42:47 +0800 Subject: [PATCH 03/64] Add discover_authorization_servers with PRM fallback and type fixes --- src/mcp/client/auth/utils.py | 52 +++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 7bcea8c34..706645658 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,12 +1,16 @@ +import json +import logging import re +from typing import Any, cast from urllib.parse import urljoin, urlparse -from httpx import Request, Response +from httpx import AsyncClient, Request, Response from pydantic import AnyUrl, ValidationError from mcp.client.auth import OAuthRegistrationError, OAuthTokenError from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.shared.auth import ( + AuthProtocolMetadata, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, @@ -15,6 +19,8 @@ ) from mcp.types import LATEST_PROTOCOL_VERSION +logger = logging.getLogger(__name__) + def extract_field_from_www_auth(response: Response, field_name: str, auth_scheme: str | None = None) -> str | None: """Extract field from WWW-Authenticate header. @@ -128,6 +134,50 @@ def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, return preferences if preferences else None +async def discover_authorization_servers( + resource_url: str, + http_client: AsyncClient, + prm: ProtectedResourceMetadata | None = None, +) -> list[AuthProtocolMetadata]: + """ + Discover supported auth protocols (unified discovery with PRM fallback). + + 1. Tries the unified capability discovery endpoint + `/.well-known/authorization_servers` (path relative to resource_url). + 2. If that fails or returns no protocols, falls back to protocol list from + PRM when provided (e.g. from a prior 401 with resource_metadata). + + Args: + resource_url: Base URL of the resource (e.g. MCP server URL). + http_client: HTTP client for the request. + prm: Optional PRM; used as fallback when unified discovery fails. + + Returns: + List of protocol metadata; empty if discovery fails and no PRM fallback. + """ + # 1. Unified discovery endpoint (path-relative to resource_url) + discovery_url = urljoin(resource_url.rstrip("/") + "/", ".well-known/authorization_servers") + try: + response = await http_client.get(discovery_url) + if response.status_code == 200: + content = await response.aread() + data = json.loads(content) + raw = data.get("protocols") + protocols_data: list[dict[str, Any]] = cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] + if protocols_data: + return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] + except (ValidationError, ValueError, KeyError, TypeError) as e: + logger.debug("Unified authorization_servers discovery failed: %s", e) + except Exception as e: + logger.debug("Unified authorization_servers request failed: %s", e) + + # 2. Fallback: use protocol list from PRM + if prm is not None and prm.mcp_auth_protocols: + return list(prm.mcp_auth_protocols) + + return [] + + def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: """Build ordered list of URLs to try for protected resource metadata discovery. From d0684656783c9c8a3ffd1a80e653f8db022d1af5 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 23:02:10 +0800 Subject: [PATCH 04/64] Add MultiProtocolAuthProvider core for multi-protocol auth --- src/mcp/client/auth/multi_protocol.py | 139 ++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/mcp/client/auth/multi_protocol.py diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py new file mode 100644 index 000000000..872cfc2a2 --- /dev/null +++ b/src/mcp/client/auth/multi_protocol.py @@ -0,0 +1,139 @@ +""" +多协议认证提供者。 + +提供基于协议注册表与发现的统一 HTTP 认证流程,支持 OAuth 2.0、API Key 等协议。 +""" + +import logging +from collections.abc import AsyncGenerator +from typing import Any, Protocol + +import anyio +import httpx + +from mcp.client.auth.protocol import AuthProtocol +from mcp.shared.auth import AuthCredentials, OAuthCredentials, OAuthToken + +logger = logging.getLogger(__name__) + + +class TokenStorage(Protocol): + """凭证存储协议(兼容 OAuthToken 与 AuthCredentials)。""" + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + """获取已存储的凭证。""" + ... + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + """存储凭证。""" + ... + + +def _oauth_token_to_credentials(token: OAuthToken) -> OAuthCredentials: + """将 OAuthToken 转为 OAuthCredentials(用于兼容现有存储)。""" + from mcp.shared.auth_utils import calculate_token_expiry + + expires_at: int | None = None + if token.expires_in is not None: + expiry = calculate_token_expiry(token.expires_in) + expires_at = int(expiry) if expiry is not None else None + return OAuthCredentials( + protocol_id="oauth2", + access_token=token.access_token, + token_type=token.token_type, + refresh_token=token.refresh_token, + scope=token.scope, + expires_at=expires_at, + ) + + +class MultiProtocolAuthProvider(httpx.Auth): + """ + 多协议认证提供者。 + + 与 httpx 集成,在请求前按所选协议准备认证信息,收到 401/403 时触发发现与认证。 + """ + + requires_response_body = True + + def __init__( + self, + server_url: str, + storage: TokenStorage, + protocols: list[AuthProtocol] | None = None, + dpop_storage: Any = None, + dpop_enabled: bool = False, + timeout: float = 300.0, + ): + self.server_url = server_url + self.storage = storage + self.protocols = protocols or [] + self.dpop_storage = dpop_storage + self.dpop_enabled = dpop_enabled + self.timeout = timeout + self._lock = anyio.Lock() + self._initialized = False + self._current_protocol: AuthProtocol | None = None + self._protocols_by_id: dict[str, AuthProtocol] = {} + + def _initialize(self) -> None: + """根据 protocols 列表构建按 protocol_id 的索引。""" + self._protocols_by_id = {p.protocol_id: p for p in self.protocols} + self._initialized = True + + def _get_protocol(self, protocol_id: str) -> AuthProtocol | None: + """按 protocol_id 获取协议实例。""" + return self._protocols_by_id.get(protocol_id) + + async def _get_credentials(self) -> AuthCredentials | None: + """ + 从存储获取凭证并规范为 AuthCredentials。 + + 若存储返回 OAuthToken,则转换为 OAuthCredentials 以保持兼容。 + """ + raw = await self.storage.get_tokens() + if raw is None: + return None + if isinstance(raw, AuthCredentials): + return raw + # raw 此时为 OAuthToken(TokenStorage 返回 AuthCredentials | OAuthToken | None) + return _oauth_token_to_credentials(raw) + + def _is_credentials_valid(self, credentials: AuthCredentials | None) -> bool: + """判断凭证是否有效(未过期等),依赖协议实现。""" + if credentials is None: + return False + protocol = self._get_protocol(credentials.protocol_id) + if protocol is None: + return False + return protocol.validate_credentials(credentials) + + def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + """为请求添加协议指定的认证信息(仅协议 prepare_request,不含 DPoP)。""" + protocol = self._get_protocol(credentials.protocol_id) + if protocol is not None: + protocol.prepare_request(request, credentials) + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """HTTPX 认证流程入口(骨架:取凭证、校验、准备请求、发送、处理 401/403)。""" + async with self._lock: + if not self._initialized: + self._initialize() + + credentials = await self._get_credentials() + if not credentials or not self._is_credentials_valid(credentials): + # TODO (TODO 9/10): _discover_and_authenticate(request) + pass + else: + self._prepare_request(request, credentials) + + response = yield request + + if response.status_code == 401: + # TODO (TODO 9): _handle_401_response(response, request) + pass + elif response.status_code == 403: + # TODO (TODO 9): _handle_403_response(response, request) + pass From 2574853465c79f251bc4d35d641d0485ba64f724 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 23:15:28 +0800 Subject: [PATCH 05/64] Add 401/403 handling skeleton to MultiProtocolAuthProvider --- src/mcp/client/auth/multi_protocol.py | 59 ++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index 872cfc2a2..d7bd89330 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -12,6 +12,13 @@ import httpx from mcp.client.auth.protocol import AuthProtocol +from mcp.client.auth.utils import ( + extract_auth_protocols_from_www_auth, + extract_default_protocol_from_www_auth, + extract_field_from_www_auth, + extract_protocol_preferences_from_www_auth, + extract_resource_metadata_from_www_auth, +) from mcp.shared.auth import AuthCredentials, OAuthCredentials, OAuthToken logger = logging.getLogger(__name__) @@ -114,17 +121,54 @@ def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) if protocol is not None: protocol.prepare_request(request, credentials) + async def _discover_and_authenticate( + self, request: httpx.Request, response: httpx.Response + ) -> None: + """ + 根据 401 响应进行协议发现与认证,并将新凭证写入 storage。 + + 具体实现见 TODO 10(协议发现 + 注册表选择 + 协议 authenticate)。 + 本骨架仅解析 WWW-Authenticate 并记录,不执行实际发现与认证。 + """ + resource_metadata_url = extract_resource_metadata_from_www_auth(response) + auth_protocols = extract_auth_protocols_from_www_auth(response) + default_protocol = extract_default_protocol_from_www_auth(response) + protocol_preferences = extract_protocol_preferences_from_www_auth(response) + if resource_metadata_url or auth_protocols or default_protocol or protocol_preferences: + logger.debug( + "401 WWW-Authenticate: resource_metadata=%s auth_protocols=%s default=%s preferences=%s", + resource_metadata_url, + auth_protocols, + default_protocol, + protocol_preferences, + ) + + async def _handle_401_response( + self, response: httpx.Response, request: httpx.Request + ) -> None: + """处理 401:解析 WWW-Authenticate,触发发现与认证(骨架),便于后续重试。""" + await self._discover_and_authenticate(request, response) + + async def _handle_403_response( + self, response: httpx.Response, request: httpx.Request + ) -> None: + """处理 403:解析 error/scope 并记录,骨架不做重试。""" + error = extract_field_from_www_auth(response, "error") + scope = extract_field_from_www_auth(response, "scope") + if error or scope: + logger.debug("403 WWW-Authenticate: error=%s scope=%s", error, scope) + async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """HTTPX 认证流程入口(骨架:取凭证、校验、准备请求、发送、处理 401/403)。""" + """HTTPX 认证流程入口:取凭证、校验、准备请求、发送、处理 401/403 并可选重试。""" async with self._lock: if not self._initialized: self._initialize() credentials = await self._get_credentials() if not credentials or not self._is_credentials_valid(credentials): - # TODO (TODO 9/10): _discover_and_authenticate(request) + # 无有效凭证时直接发送请求,依赖 401 响应后再做发现与认证(见下方 401 处理) pass else: self._prepare_request(request, credentials) @@ -132,8 +176,11 @@ async def async_auth_flow( response = yield request if response.status_code == 401: - # TODO (TODO 9): _handle_401_response(response, request) - pass + async with self._lock: + await self._handle_401_response(response, request) + credentials = await self._get_credentials() + if credentials and self._is_credentials_valid(credentials): + self._prepare_request(request, credentials) + response = yield request elif response.status_code == 403: - # TODO (TODO 9): _handle_403_response(response, request) - pass + await self._handle_403_response(response, request) From 5d80e84709fa499d2c163b48cbee25bd7a2ab211 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 23:43:51 +0800 Subject: [PATCH 06/64] Add _discover_and_authenticate full logic and storage adapter to MultiProtocolAuthProvider --- src/mcp/client/auth/multi_protocol.py | 119 +++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index d7bd89330..9673b9004 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -5,21 +5,32 @@ """ import logging +import time from collections.abc import AsyncGenerator from typing import Any, Protocol import anyio import httpx -from mcp.client.auth.protocol import AuthProtocol +from mcp.client.auth.protocol import AuthContext, AuthProtocol +from mcp.client.auth.registry import AuthProtocolRegistry from mcp.client.auth.utils import ( + build_protected_resource_metadata_discovery_urls, + discover_authorization_servers, extract_auth_protocols_from_www_auth, extract_default_protocol_from_www_auth, extract_field_from_www_auth, extract_protocol_preferences_from_www_auth, extract_resource_metadata_from_www_auth, + handle_protected_resource_response, +) +from mcp.shared.auth import ( + AuthCredentials, + AuthProtocolMetadata, + OAuthCredentials, + OAuthToken, + ProtectedResourceMetadata, ) -from mcp.shared.auth import AuthCredentials, OAuthCredentials, OAuthToken logger = logging.getLogger(__name__) @@ -54,6 +65,26 @@ def _oauth_token_to_credentials(token: OAuthToken) -> OAuthCredentials: ) +def _credentials_to_storage(credentials: AuthCredentials) -> AuthCredentials | OAuthToken: + """ + 将 AuthCredentials 转为存储可接受格式,便于兼容仅支持 OAuthToken 的旧存储。 + OAuthCredentials 转为 OAuthToken;其他凭证原样返回。 + """ + if isinstance(credentials, OAuthCredentials): + expires_in: int | None = None + if credentials.expires_at is not None: + delta = credentials.expires_at - int(time.time()) + expires_in = max(0, delta) + return OAuthToken( + access_token=credentials.access_token, + token_type=credentials.token_type, + expires_in=expires_in, + scope=credentials.scope, + refresh_token=credentials.refresh_token, + ) + return credentials + + class MultiProtocolAuthProvider(httpx.Auth): """ 多协议认证提供者。 @@ -68,6 +99,7 @@ def __init__( server_url: str, storage: TokenStorage, protocols: list[AuthProtocol] | None = None, + http_client: httpx.AsyncClient | None = None, dpop_storage: Any = None, dpop_enabled: bool = False, timeout: float = 300.0, @@ -75,6 +107,7 @@ def __init__( self.server_url = server_url self.storage = storage self.protocols = protocols or [] + self._http_client = http_client self.dpop_storage = dpop_storage self.dpop_enabled = dpop_enabled self.timeout = timeout @@ -121,28 +154,90 @@ def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) if protocol is not None: protocol.prepare_request(request, credentials) + async def _fetch_prm( + self, resource_metadata_url: str | None, server_url: str + ) -> ProtectedResourceMetadata | None: + """按 SEP-985 顺序请求 PRM:先 resource_metadata,再 well-known 回退。""" + if not self._http_client: + return None + urls = build_protected_resource_metadata_discovery_urls( + resource_metadata_url, server_url + ) + for url in urls: + try: + resp = await self._http_client.get(url) + prm = await handle_protected_resource_response(resp) + if prm is not None: + return prm + except Exception as e: + logger.debug("PRM discovery failed for %s: %s", url, e) + return None + async def _discover_and_authenticate( self, request: httpx.Request, response: httpx.Response ) -> None: """ 根据 401 响应进行协议发现与认证,并将新凭证写入 storage。 - 具体实现见 TODO 10(协议发现 + 注册表选择 + 协议 authenticate)。 - 本骨架仅解析 WWW-Authenticate 并记录,不执行实际发现与认证。 + 流程:解析 WWW-Authenticate → 可选获取 PRM → 发现协议列表 → + 注册表选择协议 → 协议 authenticate → 写回 storage(含 OAuth 凭证转 OAuthToken 适配)。 """ resource_metadata_url = extract_resource_metadata_from_www_auth(response) - auth_protocols = extract_auth_protocols_from_www_auth(response) + auth_protocols_header = extract_auth_protocols_from_www_auth(response) default_protocol = extract_default_protocol_from_www_auth(response) protocol_preferences = extract_protocol_preferences_from_www_auth(response) - if resource_metadata_url or auth_protocols or default_protocol or protocol_preferences: - logger.debug( - "401 WWW-Authenticate: resource_metadata=%s auth_protocols=%s default=%s preferences=%s", - resource_metadata_url, - auth_protocols, - default_protocol, - protocol_preferences, + + server_url = str(request.url) + prm: ProtectedResourceMetadata | None = None + protocols_metadata: list[AuthProtocolMetadata] = [] + + if self._http_client: + prm = await self._fetch_prm(resource_metadata_url, server_url) + protocols_metadata = await discover_authorization_servers( + server_url, self._http_client, prm ) + available = ( + [m.protocol_id for m in protocols_metadata] + if protocols_metadata + else (auth_protocols_header or []) + ) + if not available: + logger.debug("No available protocols from discovery or WWW-Authenticate") + return + + selected_id = AuthProtocolRegistry.select_protocol( + available, default_protocol, protocol_preferences + ) + if not selected_id: + logger.debug("No supported protocol selected for %s", available) + return + + protocol = self._get_protocol(selected_id) + if not protocol: + logger.debug("Protocol %s not in provider", selected_id) + return + + protocol_metadata: AuthProtocolMetadata | None = None + if protocols_metadata: + for m in protocols_metadata: + if m.protocol_id == selected_id: + protocol_metadata = m + break + + context = AuthContext( + server_url=server_url, + storage=self.storage, + protocol_id=selected_id, + protocol_metadata=protocol_metadata, + current_credentials=None, + dpop_storage=self.dpop_storage, + dpop_enabled=self.dpop_enabled, + ) + credentials = await protocol.authenticate(context) + to_store = _credentials_to_storage(credentials) + await self.storage.set_tokens(to_store) + async def _handle_401_response( self, response: httpx.Response, request: httpx.Request ) -> None: From 6d5b6e25d346c8489cc47bd26b5a9f09893d5c59 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 28 Jan 2026 23:56:55 +0800 Subject: [PATCH 07/64] Add CredentialVerifier and OAuthTokenVerifier with unit tests --- src/mcp/server/auth/verifiers.py | 62 +++++++++++++++++ tests/server/auth/test_verifiers.py | 102 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/mcp/server/auth/verifiers.py create mode 100644 tests/server/auth/test_verifiers.py diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py new file mode 100644 index 000000000..713d3bd15 --- /dev/null +++ b/src/mcp/server/auth/verifiers.py @@ -0,0 +1,62 @@ +""" +多协议凭证验证器。 + +提供 CredentialVerifier 协议及 OAuthTokenVerifier 实现,供 MultiProtocolAuthBackend 按协议尝试校验。 +""" + +from typing import Any, Protocol + +from starlette.requests import Request + +from mcp.server.auth.provider import AccessToken, TokenVerifier + +BEARER_PREFIX = "Bearer " +BEARER_PREFIX_LENGTH = len(BEARER_PREFIX) # 7 + + +class CredentialVerifier(Protocol): + """凭证验证器协议:按请求校验认证信息,可选 DPoP 校验(阶段4 实现)。""" + + async def verify( + self, + request: Request, + dpop_verifier: Any = None, + ) -> AccessToken | None: + """ + 校验请求中的凭证。 + + Args: + request: 待校验的请求。 + dpop_verifier: 可选 DPoP 校验器,阶段4 再使用。 + + Returns: + 校验成功时返回 AccessToken,否则返回 None。 + """ + ... + + +class OAuthTokenVerifier: + """ + OAuth Bearer 凭证验证器。 + + 封装现有 TokenVerifier,仅做 Bearer 校验;DPoP 参数占位,阶段4 再实现。 + """ + + def __init__(self, token_verifier: TokenVerifier) -> None: + self._token_verifier = token_verifier + + async def verify( + self, + request: Request, + dpop_verifier: Any = None, + ) -> AccessToken | None: + auth_header = next( + (request.headers.get(key) for key in request.headers if key.lower() == "authorization"), + None, + ) + if not auth_header or not auth_header.lower().startswith(BEARER_PREFIX.lower()): + return None + token = auth_header[BEARER_PREFIX_LENGTH :].strip() + if not token: + return None + return await self._token_verifier.verify_token(token) diff --git a/tests/server/auth/test_verifiers.py b/tests/server/auth/test_verifiers.py new file mode 100644 index 000000000..d0881c2f8 --- /dev/null +++ b/tests/server/auth/test_verifiers.py @@ -0,0 +1,102 @@ +"""Regression tests for CredentialVerifier and OAuthTokenVerifier.""" + +from typing import Any, cast + +import pytest +from starlette.requests import Request + +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import OAuthTokenVerifier + + +class _MockTokenVerifier: + """Mock TokenVerifier for testing.""" + + def __init__(self) -> None: + self._tokens: dict[str, AccessToken] = {} + + def add_token(self, token: str, access_token: AccessToken) -> None: + self._tokens[token] = access_token + + async def verify_token(self, token: str) -> AccessToken | None: + return self._tokens.get(token) + + +def _request_with_auth(value: str | None) -> Request: + scope: dict[str, Any] = {"type": "http", "headers": []} + if value is not None: + scope["headers"] = [(b"authorization", value.encode())] + return Request(scope) + + +@pytest.fixture +def mock_token_verifier() -> _MockTokenVerifier: + return _MockTokenVerifier() + + +@pytest.fixture +def oauth_verifier(mock_token_verifier: _MockTokenVerifier) -> OAuthTokenVerifier: + return OAuthTokenVerifier(cast(Any, mock_token_verifier)) + + +@pytest.fixture +def valid_access_token() -> AccessToken: + return AccessToken( + token="valid_token", + client_id="test_client", + scopes=["read", "write"], + expires_at=None, + ) + + +@pytest.mark.anyio +async def test_oauth_token_verifier_returns_none_when_no_auth_header( + oauth_verifier: OAuthTokenVerifier, +) -> None: + request = _request_with_auth(None) + result = await oauth_verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_oauth_token_verifier_returns_none_when_not_bearer( + oauth_verifier: OAuthTokenVerifier, +) -> None: + request = _request_with_auth("Basic dXNlcjpwYXNz") + result = await oauth_verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_oauth_token_verifier_returns_none_when_bearer_but_invalid( + oauth_verifier: OAuthTokenVerifier, +) -> None: + request = _request_with_auth("Bearer unknown_token") + result = await oauth_verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_oauth_token_verifier_returns_access_token_when_valid( + oauth_verifier: OAuthTokenVerifier, + mock_token_verifier: _MockTokenVerifier, + valid_access_token: AccessToken, +) -> None: + mock_token_verifier.add_token("valid_token", valid_access_token) + request = _request_with_auth("Bearer valid_token") + result = await oauth_verifier.verify(request) + assert result is not None + assert result.token == "valid_token" + assert result.client_id == "test_client" + + +@pytest.mark.anyio +async def test_oauth_token_verifier_accepts_dpop_verifier( + oauth_verifier: OAuthTokenVerifier, + mock_token_verifier: _MockTokenVerifier, + valid_access_token: AccessToken, +) -> None: + mock_token_verifier.add_token("t", valid_access_token) + request = _request_with_auth("Bearer t") + result = await oauth_verifier.verify(request, dpop_verifier=object()) + assert result is not None From ce2deee794a21ef5a14fa967288b7c10d28ebee0 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 10:09:49 +0800 Subject: [PATCH 08/64] API Key verifier: prefer X-API-Key, optional Bearer; drop ApiKey scheme --- src/mcp/server/auth/verifiers.py | 67 ++++++++++++++++++++++++- tests/server/auth/test_verifiers.py | 76 ++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py index 713d3bd15..b39e06d0b 100644 --- a/src/mcp/server/auth/verifiers.py +++ b/src/mcp/server/auth/verifiers.py @@ -11,7 +11,7 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier BEARER_PREFIX = "Bearer " -BEARER_PREFIX_LENGTH = len(BEARER_PREFIX) # 7 +APIKEY_HEADER = "x-api-key" # if found, use it; if not, use Authorization: Bearer class CredentialVerifier(Protocol): @@ -56,7 +56,70 @@ async def verify( ) if not auth_header or not auth_header.lower().startswith(BEARER_PREFIX.lower()): return None - token = auth_header[BEARER_PREFIX_LENGTH :].strip() + token = auth_header[len(BEARER_PREFIX) :].strip() if not token: return None return await self._token_verifier.verify_token(token) + + +def _get_header_ignore_case(request: Request, name: str) -> str | None: + """Get first header value matching name (case-insensitive).""" + for key in request.headers: + if key.lower() == name.lower(): + return request.headers.get(key) + return None + + +class APIKeyVerifier: + """ + API Key 凭证验证器。 + + 优先从 X-API-Key header 读取;可选从 Authorization: Bearer 读取并在 valid_keys 中查找。 + 不解析非标准 ApiKey scheme;DPoP 占位,阶段4 再实现。 + """ + + def __init__(self, valid_keys: set[str]) -> None: + self._valid_keys = valid_keys + + async def verify( + self, + request: Request, + dpop_verifier: Any = None, + ) -> AccessToken | None: + api_key: str | None = _get_header_ignore_case(request, APIKEY_HEADER) + if not api_key: + auth_header = _get_header_ignore_case(request, "authorization") + if auth_header and auth_header.strip().lower().startswith(BEARER_PREFIX.lower()): + bearer_token = auth_header[len(BEARER_PREFIX) :].strip() + if bearer_token in self._valid_keys: + api_key = bearer_token + if not api_key or api_key not in self._valid_keys: + return None + return AccessToken( + token=api_key, + client_id="api_key", + scopes=[], + expires_at=None, + ) + + +class MultiProtocolAuthBackend: + """ + 多协议认证后端。 + + 按顺序遍历 verifiers,第一个校验成功的返回其 AccessToken,否则返回 None。 + """ + + def __init__(self, verifiers: list[CredentialVerifier]) -> None: + self._verifiers = verifiers + + async def verify( + self, + request: Request, + dpop_verifier: Any = None, + ) -> AccessToken | None: + for verifier in self._verifiers: + result = await verifier.verify(request, dpop_verifier) + if result is not None: + return result + return None diff --git a/tests/server/auth/test_verifiers.py b/tests/server/auth/test_verifiers.py index d0881c2f8..4c551183b 100644 --- a/tests/server/auth/test_verifiers.py +++ b/tests/server/auth/test_verifiers.py @@ -6,7 +6,7 @@ from starlette.requests import Request from mcp.server.auth.provider import AccessToken -from mcp.server.auth.verifiers import OAuthTokenVerifier +from mcp.server.auth.verifiers import APIKeyVerifier, MultiProtocolAuthBackend, OAuthTokenVerifier class _MockTokenVerifier: @@ -29,6 +29,15 @@ def _request_with_auth(value: str | None) -> Request: return Request(scope) +def _request_with_headers(headers: list[tuple[str, str]]) -> Request: + scope: dict[str, Any] = {"type": "http", "headers": []} + if headers: + from starlette.datastructures import Headers + h = Headers(dict(headers)) + scope["headers"] = h.raw + return Request(scope) + + @pytest.fixture def mock_token_verifier() -> _MockTokenVerifier: return _MockTokenVerifier() @@ -100,3 +109,68 @@ async def test_oauth_token_verifier_accepts_dpop_verifier( request = _request_with_auth("Bearer t") result = await oauth_verifier.verify(request, dpop_verifier=object()) assert result is not None + + +@pytest.mark.anyio +async def test_api_key_verifier_returns_none_when_no_key() -> None: + verifier = APIKeyVerifier(valid_keys={"key1"}) + request = _request_with_headers([]) + result = await verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_api_key_verifier_accepts_x_api_key_header() -> None: + verifier = APIKeyVerifier(valid_keys={"secret-key-123"}) + request = _request_with_headers([("X-API-Key", "secret-key-123")]) + result = await verifier.verify(request) + assert result is not None + assert result.token == "secret-key-123" + assert result.client_id == "api_key" + + +@pytest.mark.anyio +async def test_api_key_verifier_accepts_bearer_when_key_in_valid_keys() -> None: + verifier = APIKeyVerifier(valid_keys={"mykey"}) + request = _request_with_headers([("Authorization", "Bearer mykey")]) + result = await verifier.verify(request) + assert result is not None + assert result.token == "mykey" + + +@pytest.mark.anyio +async def test_api_key_verifier_rejects_authorization_apikey_scheme() -> None: + verifier = APIKeyVerifier(valid_keys={"mykey"}) + request = _request_with_headers([("Authorization", "ApiKey mykey")]) + result = await verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_api_key_verifier_returns_none_when_key_invalid() -> None: + verifier = APIKeyVerifier(valid_keys={"valid"}) + request = _request_with_headers([("X-API-Key", "invalid")]) + result = await verifier.verify(request) + assert result is None + + +@pytest.mark.anyio +async def test_multi_protocol_backend_returns_first_success() -> None: + oauth_verifier = OAuthTokenVerifier(cast(Any, _MockTokenVerifier())) + api_key_verifier = APIKeyVerifier(valid_keys={"k1"}) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier]) + request = _request_with_headers([("X-API-Key", "k1")]) + result = await backend.verify(request) + assert result is not None + assert result.token == "k1" + + +@pytest.mark.anyio +async def test_multi_protocol_backend_returns_none_when_all_fail() -> None: + backend = MultiProtocolAuthBackend(verifiers=[ + OAuthTokenVerifier(cast(Any, _MockTokenVerifier())), + APIKeyVerifier(valid_keys=set()), + ]) + request = _request_with_headers([]) + result = await backend.verify(request) + assert result is None From e6893a4d69e2a31a6b2740821dac0edb63c88147 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 11:02:18 +0800 Subject: [PATCH 09/64] Extend create_protected_resource_routes with auth_protocols, default_protocol, protocol_preferences --- src/mcp/server/auth/routes.py | 14 +++++- tests/server/auth/test_protected_resource.py | 52 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 08f735f36..6fc7834cd 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -18,7 +18,7 @@ from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER -from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata +from mcp.shared.auth import AuthProtocolMetadata, OAuthMetadata, ProtectedResourceMetadata def validate_issuer_url(url: AnyHttpUrl): @@ -210,6 +210,9 @@ def create_protected_resource_routes( scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, + auth_protocols: list[AuthProtocolMetadata] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, ) -> list[Route]: """Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). @@ -217,6 +220,11 @@ def create_protected_resource_routes( resource_url: The URL of this resource server authorization_servers: List of authorization servers that can issue tokens scopes_supported: Optional list of scopes supported by this resource + resource_name: Optional human-readable name for the resource + resource_documentation: Optional URL to resource documentation + auth_protocols: Optional MCP extension list of AuthProtocolMetadata + default_protocol: Optional MCP extension default protocol ID + protocol_preferences: Optional MCP extension protocol ID to priority Returns: List of Starlette routes for protected resource metadata @@ -227,7 +235,9 @@ def create_protected_resource_routes( scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, - # bearer_methods_supported defaults to ["header"] in the model + mcp_auth_protocols=auth_protocols, + mcp_default_auth_protocol=default_protocol, + mcp_auth_protocol_preferences=protocol_preferences, ) handler = ProtectedResourceMetadataHandler(metadata) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 413a80276..94dd61cf1 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -9,6 +9,7 @@ from starlette.applications import Starlette from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes +from mcp.shared.auth import AuthProtocolMetadata @pytest.fixture @@ -196,3 +197,54 @@ def test_route_consistency_consistent_paths_for_various_resources(resource_url: assert url_path == expected_path assert route_path == expected_path assert url_path == route_path + + +@pytest.fixture +def multiprotocol_app() -> Starlette: + """Fixture for protected resource with mcp_* extension (auth_protocols, default_protocol, protocol_preferences).""" + routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=["read"], + auth_protocols=[ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0"), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1"), + ], + default_protocol="oauth2", + protocol_preferences={"oauth2": 1, "api_key": 2}, + ) + return Starlette(routes=routes) + + +@pytest.fixture +async def multiprotocol_client(multiprotocol_app: Starlette): + """HTTP client for multiprotocol protected resource app.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=multiprotocol_app), base_url="https://mcptest.com" + ) as client: + yield client + + +@pytest.mark.anyio +async def test_metadata_includes_mcp_auth_protocols(multiprotocol_client: httpx.AsyncClient) -> None: + """PRM endpoint returns mcp_auth_protocols, mcp_default_auth_protocol, mcp_auth_protocol_preferences when provided.""" + response = await multiprotocol_client.get("/.well-known/oauth-protected-resource/mcp") + assert response.status_code == 200 + data = response.json() + assert "mcp_auth_protocols" in data + assert len(data["mcp_auth_protocols"]) == 2 + assert data["mcp_auth_protocols"][0]["protocol_id"] == "oauth2" + assert data["mcp_auth_protocols"][1]["protocol_id"] == "api_key" + assert data.get("mcp_default_auth_protocol") == "oauth2" + assert data.get("mcp_auth_protocol_preferences") == {"oauth2": 1, "api_key": 2} + + +@pytest.mark.anyio +async def test_metadata_without_mcp_params_has_no_mcp_fields(root_resource_client: httpx.AsyncClient) -> None: + """When multiprotocol params are not passed, PRM must not add mcp_* fields implicitly.""" + response = await root_resource_client.get("/.well-known/oauth-protected-resource") + assert response.status_code == 200 + data = response.json() + assert "mcp_auth_protocols" not in data + assert "mcp_default_auth_protocol" not in data + assert "mcp_auth_protocol_preferences" not in data From b7b04d9696d3ba51aa993cecd9fea593291de6fa Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 14:25:11 +0800 Subject: [PATCH 10/64] Add AuthorizationServersDiscoveryHandler and create_authorization_servers_discovery_routes --- src/mcp/server/auth/handlers/discovery.py | 35 +++++++++ src/mcp/server/auth/routes.py | 35 +++++++++ tests/server/auth/test_discovery.py | 93 +++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/mcp/server/auth/handlers/discovery.py create mode 100644 tests/server/auth/test_discovery.py diff --git a/src/mcp/server/auth/handlers/discovery.py b/src/mcp/server/auth/handlers/discovery.py new file mode 100644 index 000000000..152c1a26c --- /dev/null +++ b/src/mcp/server/auth/handlers/discovery.py @@ -0,0 +1,35 @@ +"""Unified authorization servers discovery handler (/.well-known/authorization_servers).""" + +from dataclasses import dataclass + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.shared.auth import AuthProtocolMetadata + + +@dataclass +class AuthorizationServersDiscoveryHandler: + """ + Handler for /.well-known/authorization_servers. + + Returns JSON with protocols (list of AuthProtocolMetadata), optional default_protocol, + and optional protocol_preferences. Clients use "protocols" for discovery. + """ + + protocols: list[AuthProtocolMetadata] + default_protocol: str | None = None + protocol_preferences: dict[str, int] | None = None + + async def handle(self, request: Request) -> Response: + content: dict[str, object] = { + "protocols": [p.model_dump(mode="json", exclude_none=True) for p in self.protocols], + } + if self.default_protocol is not None: + content["default_protocol"] = self.default_protocol + if self.protocol_preferences is not None: + content["protocol_preferences"] = self.protocol_preferences + return JSONResponse( + content, + headers={"Cache-Control": "public, max-age=3600"}, + ) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 6fc7834cd..1266facf9 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -11,6 +11,8 @@ from mcp.server.auth.handlers.authorize import AuthorizationHandler from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler +from mcp.server.auth.handlers.discovery import AuthorizationServersDiscoveryHandler +from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler from mcp.server.auth.handlers.register import RegistrationHandler from mcp.server.auth.handlers.revoke import RevocationHandler from mcp.server.auth.handlers.token import TokenHandler @@ -255,3 +257,36 @@ def create_protected_resource_routes( methods=["GET", "OPTIONS"], ) ] + + +AUTHORIZATION_SERVERS_DISCOVERY_PATH = "/.well-known/authorization_servers" + + +def create_authorization_servers_discovery_routes( + protocols: list[AuthProtocolMetadata], + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, +) -> list[Route]: + """ + Create routes for unified authorization servers discovery (/.well-known/authorization_servers). + + Args: + protocols: List of supported auth protocol metadata. + default_protocol: Optional default protocol ID. + protocol_preferences: Optional protocol ID to priority mapping. + + Returns: + List of Starlette routes for the discovery endpoint. + """ + handler = AuthorizationServersDiscoveryHandler( + protocols=protocols, + default_protocol=default_protocol, + protocol_preferences=protocol_preferences, + ) + return [ + Route( + AUTHORIZATION_SERVERS_DISCOVERY_PATH, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=["GET", "OPTIONS"], + ) + ] diff --git a/tests/server/auth/test_discovery.py b/tests/server/auth/test_discovery.py new file mode 100644 index 000000000..713ef0286 --- /dev/null +++ b/tests/server/auth/test_discovery.py @@ -0,0 +1,93 @@ +"""Regression tests for AuthorizationServersDiscoveryHandler and create_authorization_servers_discovery_routes.""" + +from typing import cast + +import httpx +import pytest +from starlette.applications import Starlette + +from mcp.server.auth.routes import create_authorization_servers_discovery_routes +from mcp.shared.auth import AuthProtocolMetadata + + +@pytest.fixture +def discovery_app() -> Starlette: + """App with /.well-known/authorization_servers returning protocols, default_protocol, protocol_preferences.""" + routes = create_authorization_servers_discovery_routes( + protocols=[ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0"), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1"), + ], + default_protocol="oauth2", + protocol_preferences={"oauth2": 1, "api_key": 2}, + ) + return Starlette(routes=routes) + + +@pytest.fixture +async def discovery_client(discovery_app: Starlette): + """HTTP client for discovery app.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=discovery_app), base_url="https://mcptest.com" + ) as client: + yield client + + +@pytest.mark.anyio +async def test_discovery_endpoint_returns_protocols(discovery_client: httpx.AsyncClient) -> None: + """GET /.well-known/authorization_servers returns 200 with protocols list.""" + response = await discovery_client.get("/.well-known/authorization_servers") + assert response.status_code == 200 + data = response.json() + assert "protocols" in data + assert len(data["protocols"]) == 2 + assert data["protocols"][0]["protocol_id"] == "oauth2" + assert data["protocols"][1]["protocol_id"] == "api_key" + + +@pytest.mark.anyio +async def test_discovery_endpoint_includes_default_and_preferences(discovery_client: httpx.AsyncClient) -> None: + """Response includes default_protocol and protocol_preferences when provided.""" + response = await discovery_client.get("/.well-known/authorization_servers") + assert response.status_code == 200 + data = response.json() + assert data.get("default_protocol") == "oauth2" + assert data.get("protocol_preferences") == {"oauth2": 1, "api_key": 2} + + +@pytest.mark.anyio +async def test_discovery_response_parseable_by_client() -> None: + """Response format is parseable by discover_authorization_servers (AuthProtocolMetadata.model_validate).""" + routes = create_authorization_servers_discovery_routes( + protocols=[AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0")], + ) + app = Starlette(routes=routes) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com" + ) as client: + response = await client.get("/.well-known/authorization_servers") + assert response.status_code == 200 + data = response.json() + raw = cast(list[dict[str, object]] | None, data.get("protocols")) + assert raw is not None and len(raw) == 1 + parsed = AuthProtocolMetadata.model_validate(raw[0]) + assert parsed.protocol_id == "oauth2" + assert parsed.protocol_version == "2.0" + + +@pytest.mark.anyio +async def test_discovery_routes_minimal_protocols_only() -> None: + """create_authorization_servers_discovery_routes with only protocols (no default/preferences).""" + routes = create_authorization_servers_discovery_routes( + protocols=[AuthProtocolMetadata(protocol_id="api_key", protocol_version="1")], + ) + app = Starlette(routes=routes) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com" + ) as client: + response = await client.get("/.well-known/authorization_servers") + assert response.status_code == 200 + data = response.json() + assert data["protocols"][0]["protocol_id"] == "api_key" + assert "default_protocol" not in data or data.get("default_protocol") is None + assert "protocol_preferences" not in data or data.get("protocol_preferences") is None From ed8a520eee7f6c8b96f7075dd6eeea9afc64a1fe Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 15:21:52 +0800 Subject: [PATCH 11/64] Add Phase 2 auth integration test and client auth protocol module --- src/mcp/client/auth/protocol.py | 149 +++++++++++++++++++ tests/client/test_auth_integration_phase2.py | 77 ++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/mcp/client/auth/protocol.py create mode 100644 tests/client/test_auth_integration_phase2.py diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py new file mode 100644 index 000000000..ab67e723f --- /dev/null +++ b/src/mcp/client/auth/protocol.py @@ -0,0 +1,149 @@ +""" +授权协议抽象接口定义。 + +提供多协议授权支持的统一接口抽象。 +""" + +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx + +from mcp.shared.auth import AuthCredentials, AuthProtocolMetadata, ProtectedResourceMetadata + + +# DPoP相关类型占位符(阶段4实现) +class DPoPStorage(Protocol): + """DPoP密钥对存储接口(阶段4实现)""" + + async def get_key_pair(self, protocol_id: str) -> Any: ... + async def set_key_pair(self, protocol_id: str, key_pair: Any) -> None: ... + + +class DPoPProofGenerator(Protocol): + """DPoP证明生成器接口(阶段4实现)""" + + def generate_proof(self, method: str, uri: str, credential: str | None = None, nonce: str | None = None) -> str: ... + def get_public_key_jwk(self) -> dict[str, Any]: ... + + +class ClientRegistrationResult(Protocol): + """客户端注册结果接口""" + + client_id: str + client_secret: str | None = None + + +@dataclass +class AuthContext: + """通用认证上下文""" + + server_url: str + storage: Any # TokenStorage协议类型 + protocol_id: str + protocol_metadata: AuthProtocolMetadata | None = None + current_credentials: AuthCredentials | None = None + dpop_storage: DPoPStorage | None = None + dpop_enabled: bool = False + + +class AuthProtocol(Protocol): + """授权协议基础接口(所有协议必须实现)""" + + protocol_id: str + protocol_version: str + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + """ + 执行认证流程,获取凭证。 + + Args: + context: 认证上下文 + + Returns: + 认证凭证 + """ + ... + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + """ + 准备HTTP请求,添加认证信息。 + + Args: + request: HTTP请求对象 + credentials: 认证凭证 + """ + ... + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + """ + 验证凭证是否有效(未过期等)。 + + Args: + credentials: 待验证的凭证 + + Returns: + True if credentials are valid, False otherwise + """ + ... + + async def discover_metadata( + self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + """ + 发现协议元数据。 + + Args: + metadata_url: 元数据URL(可选) + prm: 受保护资源元数据(可选) + + Returns: + 协议元数据,如果发现失败则返回None + """ + ... + + +class ClientRegisterableProtocol(AuthProtocol): + """支持客户端注册的协议扩展接口""" + + async def register_client(self, context: AuthContext) -> ClientRegistrationResult | None: + """ + 注册客户端。 + + Args: + context: 认证上下文 + + Returns: + 客户端注册结果,如果注册失败或不需要注册则返回None + """ + ... + + +class DPoPEnabledProtocol(AuthProtocol): + """支持DPoP的协议扩展接口(阶段4实现)""" + + def supports_dpop(self) -> bool: + """ + 检查协议是否支持DPoP。 + + Returns: + True if protocol supports DPoP, False otherwise + """ + ... + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + """ + 获取DPoP证明生成器。 + + Returns: + DPoP证明生成器,如果协议不支持DPoP则返回None + """ + ... + + async def initialize_dpop(self) -> None: + """ + 初始化DPoP(生成密钥对等)。 + + 仅在协议支持DPoP时调用。 + """ + ... diff --git a/tests/client/test_auth_integration_phase2.py b/tests/client/test_auth_integration_phase2.py new file mode 100644 index 000000000..433397f14 --- /dev/null +++ b/tests/client/test_auth_integration_phase2.py @@ -0,0 +1,77 @@ +""" +Phase2 integration tests: unified discovery endpoint and 401 WWW-Authenticate auth_protocols extension. + +- Client requests /.well-known/authorization_servers and gets protocol list. +- Server 401 header contains auth_protocols/default_protocol/protocol_preferences and client parses them. +- Phase1 regression: run ./scripts/run_phase1_oauth2_integration_test.sh (see plan). +""" + +import httpx +import pytest +from starlette.applications import Starlette + +from mcp.client.auth.utils import ( + discover_authorization_servers, + extract_auth_protocols_from_www_auth, + extract_default_protocol_from_www_auth, + extract_protocol_preferences_from_www_auth, +) +from mcp.server.auth.routes import create_authorization_servers_discovery_routes +from mcp.shared.auth import AuthProtocolMetadata + + +@pytest.mark.anyio +async def test_client_discovers_protocols_via_unified_endpoint_integration() -> None: + """Integration: app serves /.well-known/authorization_servers, client discover_authorization_servers returns protocols.""" + routes = create_authorization_servers_discovery_routes( + protocols=[ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0"), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1"), + ], + default_protocol="oauth2", + protocol_preferences={"oauth2": 1, "api_key": 2}, + ) + app = Starlette(routes=routes) + base_url = "https://example.com" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url=base_url, + ) as client: + result = await discover_authorization_servers(base_url, client) + assert len(result) == 2 + assert result[0].protocol_id == "oauth2" + assert result[1].protocol_id == "api_key" + + +@pytest.mark.anyio +async def test_client_parses_401_www_authenticate_auth_protocols_extension() -> None: + """401 WWW-Authenticate with auth_protocols, default_protocol, protocol_preferences; client extractors return correct values.""" + www_auth = ( + 'Bearer auth_protocols="oauth2 api_key", default_protocol="oauth2", protocol_preferences="oauth2:1,api_key:2"' + ) + response = httpx.Response( + 401, + headers={"WWW-Authenticate": www_auth}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + protocols = extract_auth_protocols_from_www_auth(response) + assert protocols is not None + assert protocols == ["oauth2", "api_key"] + default = extract_default_protocol_from_www_auth(response) + assert default == "oauth2" + prefs = extract_protocol_preferences_from_www_auth(response) + assert prefs is not None + assert prefs == {"oauth2": 1, "api_key": 2} + + +@pytest.mark.anyio +async def test_client_parses_401_without_auth_protocols_extension_returns_none() -> None: + """401 WWW-Authenticate without auth_protocols extension; extractors return None.""" + response = httpx.Response( + 401, + headers={"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + assert extract_auth_protocols_from_www_auth(response) is None + assert extract_default_protocol_from_www_auth(response) is None + assert extract_protocol_preferences_from_www_auth(response) is None From 995a1b1fbdd6244279aea7a406d2074aaa5c0af6 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 15:35:41 +0800 Subject: [PATCH 12/64] Add Phase 1 OAuth2 integration test script and regression test plan --- scripts/run_phase1_oauth2_integration_test.sh | 71 ++++++++++ tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md | 129 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100755 scripts/run_phase1_oauth2_integration_test.sh create mode 100644 tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md diff --git a/scripts/run_phase1_oauth2_integration_test.sh b/scripts/run_phase1_oauth2_integration_test.sh new file mode 100755 index 000000000..d6d19657f --- /dev/null +++ b/scripts/run_phase1_oauth2_integration_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Phase 1 OAuth2 integration test: start simple-auth (AS + RS) and run simple-auth-client. +# Usage: from repo root, run: ./scripts/run_phase1_oauth2_integration_test.sh +# You must complete OAuth in the browser and run list / call get_time / quit at the mcp> prompt. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" +SIMPLE_AUTH_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-client" +AS_PORT=9000 +RS_PORT=8001 + +cd "$REPO_ROOT" +echo "Repo root: $REPO_ROOT" + +# Ensure deps (simple-auth and simple-auth-client are workspace examples) +uv sync --quiet 2>/dev/null || true + +wait_for_url() { + local url="$1" + local name="$2" + local max=30 + local n=0 + while ! curl -sSf -o /dev/null "$url" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -ge "$max" ]; then + echo "Timeout waiting for $name at $url" + return 1 + fi + sleep 0.5 + done + echo "$name is up at $url" +} + +cleanup() { + echo "Stopping servers..." + [ -n "$AS_PID" ] && kill "$AS_PID" 2>/dev/null || true + [ -n "$RS_PID" ] && kill "$RS_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT + +# Start Authorization Server +cd "$SIMPLE_AUTH_SERVER" +uv run mcp-simple-auth-as --port="$AS_PORT" & +AS_PID=$! +cd "$REPO_ROOT" + +# Start Resource Server +cd "$SIMPLE_AUTH_SERVER" +uv run mcp-simple-auth-rs --port="$RS_PORT" --auth-server="http://localhost:$AS_PORT" --transport=streamable-http & +RS_PID=$! +cd "$REPO_ROOT" + +# Wait for AS and RS (PRM path includes /mcp when server_url is http://localhost:8001/mcp) +wait_for_url "http://localhost:$AS_PORT/.well-known/oauth-authorization-server" "Authorization Server" +wait_for_url "http://localhost:$RS_PORT/.well-known/oauth-protected-resource/mcp" "Resource Server (PRM)" + +# Optional: print PRM (Phase 1 backward compat: resource + authorization_servers; mcp_* may appear) +echo "" +echo "PRM (RFC 9728 + optional Phase 1 fields):" +curl -sS "http://localhost:$RS_PORT/.well-known/oauth-protected-resource/mcp" | head -c 500 +echo "" +echo "" + +# Run client (foreground); user completes OAuth in browser and runs list / call get_time / quit +echo "Starting simple-auth-client. Complete OAuth in the browser, then run: list, call get_time {}, quit" +echo "" +cd "$SIMPLE_AUTH_CLIENT" +MCP_SERVER_PORT="$RS_PORT" MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client diff --git a/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md b/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md new file mode 100644 index 000000000..93035a9ca --- /dev/null +++ b/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md @@ -0,0 +1,129 @@ +# Phase 1 OAuth2 Regression Test Plan + +Verify that Phase 1 (multi-protocol auth infrastructure) does **not** break existing MCP OAuth2 authentication. The existing flow uses only RFC 9728 / Bearer and does not pass the new optional parameters; all new code must remain backward compatible. + +--- + +## 1. Objectives + +| Objective | Description | +|-----------|-------------| +| **Backward compatibility** | Existing OAuth2 client and server behavior unchanged when new optional params are not used. | +| **Discovery** | Client still discovers PRM and AS metadata; server still returns RFC 9728 PRM and correct WWW-Authenticate. | +| **End-to-end** | Full flow with `simple-auth` (AS + RS) and `simple-auth-client` completes: 401 → discovery → OAuth → token → MCP session → list tools → call tool. | + +--- + +## 2. Scope of Phase 1 Code Under Test + +| Area | Change | Backward compatibility | +|------|--------|-------------------------| +| **shared/auth.py** | `AuthProtocolMetadata`, `AuthCredentials`/`OAuthCredentials`/`APIKeyCredentials`, `ProtectedResourceMetadata` extended with `mcp_*` fields and `@model_validator` | PRM built from `authorization_servers` only still works; validator fills `mcp_auth_protocols` when absent. | +| **client/auth/protocol.py** | New file; `AuthProtocol`, `AuthContext`, etc. | Not used by existing OAuth path; no impact. | +| **client/auth/utils.py** | `extract_field_from_www_auth(..., auth_scheme=None)`, new extractors for `auth_protocols` / `default_protocol` / `protocol_preferences` | When `auth_scheme` is not passed, behavior unchanged (search full header). New extractors unused by current client. | +| **server/auth/middleware/bearer_auth.py** | `RequireAuthMiddleware` accepts optional `auth_protocols`, `default_protocol`, `protocol_preferences`; `_determine_auth_scheme`; WWW-Authenticate may include new params | When new params are not passed (current FastMCP/routes), middleware behaves as before: Bearer scheme, no new header params. | + +--- + +## 3. Unit / Regression Tests + +Run existing tests to ensure no regressions. Phase 1 does not change call sites: FastMCP still calls `RequireAuthMiddleware(app, required_scopes, resource_metadata_url)` without the new optional args; client still uses `extract_field_from_www_auth(response, "resource_metadata")` etc. without `auth_scheme`. + +### 3.1 Data model + +- **ProtectedResourceMetadata** + - Construct with only `resource` and `authorization_servers` (no `mcp_*`). + - After validation, `mcp_auth_protocols` is populated from `authorization_servers` and `mcp_default_auth_protocol == "oauth2"`. + - Existing tests in `tests/client/test_auth.py` (e.g. `TestProtectedResourceMetadata`) and any that build `ProtectedResourceMetadata` must still pass. + +### 3.2 Client utils + +- **extract_field_from_www_auth** + - Call with `auth_scheme=None` (default): existing behavior (search full header). + - Tests in `test_extract_field_from_www_auth_valid_cases` and `test_extract_field_from_www_auth_invalid_cases` must pass unchanged. +- **extract_resource_metadata_from_www_auth**, **extract_scope_from_www_auth**: unchanged signatures; existing tests remain valid. + +### 3.3 Server middleware + +- **RequireAuthMiddleware** + - Instantiate with only `(app, required_scopes, resource_metadata_url)`. + - WWW-Authenticate must still start with `Bearer ` and include `error`, `error_description`, and optionally `resource_metadata`; no requirement for `auth_protocols` / `default_protocol` / `protocol_preferences`. +- Existing tests in `tests/server/auth/middleware/test_bearer_auth.py` (e.g. `TestRequireAuthMiddleware`) must pass. + +### 3.4 Commands + +```bash +# From repo root +uv run pytest tests/client/test_auth.py tests/server/auth/middleware/test_bearer_auth.py -v +``` + +--- + +## 4. Integration Test: simple-auth + simple-auth-client + +Manual (or script-assisted) run to confirm the full OAuth2 flow still works with Phase 1 code. + +### 4.1 Prerequisites + +- Repo root: `uv sync` (so `mcp-simple-auth`, `mcp-simple-auth-client`, and SDK are available). +- Ports 9000 (AS), 8001 (RS), 3030 (client callback) free. + +### 4.2 Steps + +1. **Start Authorization Server (AS)** + From `examples/servers/simple-auth`: + ```bash + uv run mcp-simple-auth-as --port=9000 + ``` + +2. **Start Resource Server (RS)** + In another terminal, from `examples/servers/simple-auth`: + ```bash + uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http + ``` + +3. **Optional: Verify discovery (Phase 1 backward compat)** + - PRM (RFC 9728): `curl -s http://localhost:8001/.well-known/oauth-protected-resource` + - Must return JSON with `resource` and `authorization_servers` (and may include Phase 1 `mcp_*` if implementation fills them). + - AS metadata: `curl -s http://localhost:9000/.well-known/oauth-authorization-server` + - Must return JSON with `issuer`, `authorization_endpoint`, `token_endpoint`. + +4. **Run client** + From `examples/clients/simple-auth-client`: + ```bash + MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client + ``` + +5. **Complete OAuth in browser** + When the client prints the authorization URL, open it in a browser, complete the simple-auth login; redirect to `http://localhost:3030/callback`. + +6. **Verify MCP session** + At `mcp>` prompt: + - `list` → should list tools (e.g. `get_time`). + - `call get_time {}` → should return current time. + - `quit` → exit. + +### 4.3 Success criteria + +- No errors during discovery (client gets PRM and AS metadata). +- OAuth flow completes (authorization code → token). +- Client connects and initializes MCP session. +- `list` and `call get_time` succeed. +- WWW-Authenticate on 401 (if inspected) remains Bearer-based and usable by the existing client. + +--- + +## 5. Automated Script (Optional) + +Use the script `scripts/run_phase1_oauth2_integration_test.sh` to start AS and RS, wait for readiness, then run the client. You still complete OAuth in the browser and run `list` / `call get_time` / `quit` manually. + +--- + +## 6. Checklist Summary + +- [ ] `uv run pytest tests/client/test_auth.py tests/server/auth/middleware/test_bearer_auth.py -v` passes. +- [ ] AS and RS start without errors. +- [ ] PRM and AS discovery URLs return valid JSON. +- [ ] simple-auth-client completes OAuth and connects. +- [ ] `list` shows tools; `call get_time {}` returns time. +- [ ] No Phase 1 code paths required for this flow (optional params unused). From 1a37dd800455e099ca398c836c10144d633c1d52 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 16:27:43 +0800 Subject: [PATCH 13/64] Add simple-auth-multiprotocol server example (OAuth, API Key, mTLS placeholder) --- .../simple-auth-multiprotocol/README.md | 29 +++ .../mcp_simple_auth_multiprotocol/__init__.py | 1 + .../mcp_simple_auth_multiprotocol/__main__.py | 7 + .../multiprotocol.py | 67 +++++ .../mcp_simple_auth_multiprotocol/server.py | 235 ++++++++++++++++++ .../token_verifier.py | 80 ++++++ .../simple-auth-multiprotocol/pyproject.toml | 31 +++ uv.lock | 42 ++++ 8 files changed, 492 insertions(+) create mode 100644 examples/servers/simple-auth-multiprotocol/README.md create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__init__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py create mode 100644 examples/servers/simple-auth-multiprotocol/pyproject.toml diff --git a/examples/servers/simple-auth-multiprotocol/README.md b/examples/servers/simple-auth-multiprotocol/README.md new file mode 100644 index 000000000..5e3c2947d --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/README.md @@ -0,0 +1,29 @@ +# simple-auth-multiprotocol + +MCP Resource Server example that supports **OAuth 2.0** (introspection), **API Key** (X-API-Key or Bearer \), and **Mutual TLS** (placeholder). + +- Uses `MultiProtocolAuthBackend` with `OAuthTokenVerifier`, `APIKeyVerifier`, and a Mutual TLS placeholder verifier. +- PRM and `RequireAuthMiddleware` use `auth_protocols` (oauth2, api_key, mutual_tls), `default_protocol`, and `protocol_preferences`. +- Serves `/.well-known/authorization_servers` for unified discovery. + +## Run + +1. Start the Authorization Server (same as simple-auth): + From `examples/servers/simple-auth`: `uv run mcp-simple-auth-as --port=9000` + +2. Start this Resource Server: + From this directory: `uv run mcp-simple-auth-multiprotocol-rs --port=8002 --auth-server=http://localhost:9000` + +3. Use OAuth (e.g. simple-auth-client) or API Key: + - OAuth: same as simple-auth (401 → discovery → OAuth → token → MCP). + - API Key: set header `X-API-Key: demo-api-key-12345` or `Authorization: Bearer demo-api-key-12345` (default key). + Custom keys: `--api-keys=key1,key2`. + +## Options + +- `--port`: RS port (default 8002). +- `--auth-server`: AS URL (default http://localhost:9000). +- `--api-keys`: Comma-separated valid API keys (default demo-api-key-12345). +- `--oauth-strict`: Enable RFC 8707 resource validation. + +Mutual TLS is a placeholder (no client certificate validation). diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__init__.py new file mode 100644 index 000000000..c4c0bf132 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__init__.py @@ -0,0 +1 @@ +"""MCP Resource Server with multi-protocol auth (OAuth, API Key, Mutual TLS placeholder).""" diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py new file mode 100644 index 000000000..00308da6d --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py @@ -0,0 +1,7 @@ +"""Entry point for multi-protocol MCP Resource Server.""" + +import sys + +from mcp_simple_auth_multiprotocol.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py new file mode 100644 index 000000000..5e4b0e3d0 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py @@ -0,0 +1,67 @@ +"""Multi-protocol auth: adapter for Starlette and Mutual TLS placeholder verifier.""" + +import time +from typing import Any, cast + +from starlette.authentication import AuthCredentials, AuthenticationBackend +from starlette.requests import HTTPConnection, Request + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import ( + APIKeyVerifier, + CredentialVerifier, + MultiProtocolAuthBackend, + OAuthTokenVerifier, +) + + +class MutualTLSVerifier: + """ + Placeholder verifier for Mutual TLS. + + Does not validate client certificates; returns None. Real mTLS validation + would inspect the TLS connection for client certificate and verify it. + """ + + async def verify( + self, + request: Any, + dpop_verifier: Any = None, + ) -> AccessToken | None: + return None + + +def build_multiprotocol_backend( + oauth_token_verifier: Any, + api_key_valid_keys: set[str], +) -> MultiProtocolAuthBackend: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) + api_key_verifier = APIKeyVerifier(valid_keys=api_key_valid_keys) + mtls_verifier: CredentialVerifier = MutualTLSVerifier() + return MultiProtocolAuthBackend( + verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] + ) + + +class MultiProtocolAuthBackendAdapter(AuthenticationBackend): + """ + Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend. + + Converts AccessToken from backend.verify() into (AuthCredentials, AuthenticatedUser). + """ + + def __init__(self, backend: MultiProtocolAuthBackend) -> None: + self._backend = backend + + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: + result = await self._backend.verify(cast(Request, conn)) + if result is None: + return None + if result.expires_at is not None and result.expires_at < int(time.time()): + return None + return ( + AuthCredentials(result.scopes or []), + AuthenticatedUser(result), + ) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py new file mode 100644 index 000000000..aa3199e20 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -0,0 +1,235 @@ +""" +MCP Resource Server with multi-protocol auth (OAuth, API Key, Mutual TLS placeholder). + +Uses MultiProtocolAuthBackend, PRM with auth_protocols, and /.well-known/authorization_servers. +""" + +import contextlib +import datetime +import logging +from typing import Any, Literal + +import click +import uvicorn +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Route +from starlette.types import ASGIApp + +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_authorization_servers_discovery_routes, + create_protected_resource_routes, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.shared.auth import AuthProtocolMetadata + +from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the multi-protocol MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + host: str = "localhost" + port: int = 8002 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + mcp_scope: str = "user" + oauth_strict: bool = False + api_key_valid_keys: str = "demo-api-key-12345" + default_protocol: str = "oauth2" + protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + + +def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: + """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" + auth_base = str(settings.auth_server_url).rstrip("/") + oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") + return [ + AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="1.0", + metadata_url=oauth_metadata_url, + scopes_supported=[settings.mcp_scope], + ), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), + ] + + +def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: + """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" + out: dict[str, int] = {} + for part in prefs_str.split(","): + s = part.strip() + if ":" in s: + proto, prio = s.split(":", 1) + try: + out[proto.strip()] = int(prio.strip()) + except ValueError: + pass + return out + + +def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: + """Create Starlette app with MultiProtocolAuthBackend, PRM, and discovery routes.""" + oauth_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, + ) + api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} + backend = build_multiprotocol_backend(oauth_verifier, api_key_keys) + adapter = MultiProtocolAuthBackendAdapter(backend) + + fastmcp = FastMCP( + name="MCP Resource Server (multiprotocol)", + instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth", + host=settings.host, + port=settings.port, + auth=None, + ) + + @fastmcp.tool() + async def get_time() -> dict[str, Any]: + """Return current server time (requires auth).""" + now = datetime.datetime.now() + return { + "current_time": now.isoformat(), + "timezone": "UTC", + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + mcp_server = getattr(fastmcp, "_mcp_server") + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + retry_interval=None, + json_response=False, + stateless=False, + security_settings=None, + ) + streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) + + auth_settings = AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ) + resource_url = auth_settings.resource_server_url + assert resource_url is not None + resource_metadata_url = build_resource_metadata_url(resource_url) + protocols_metadata = _protocol_metadata_list(settings) + auth_protocol_ids = [p.protocol_id for p in protocols_metadata] + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + + require_auth = RequireAuthMiddleware( + streamable_app, + required_scopes=[settings.mcp_scope], + resource_metadata_url=resource_metadata_url, + auth_protocols=auth_protocol_ids, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + + routes: list[Route] = [ + Route( + "/mcp", + endpoint=require_auth, + ), + ] + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + auth_protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + ) + routes.extend( + create_authorization_servers_discovery_routes( + protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + ) + + middleware = [ + Middleware(AuthenticationMiddleware, backend=adapter), + Middleware(AuthContextMiddleware), + ] + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with session_manager.run(): + yield + + return Starlette( + debug=True, + routes=routes, + middleware=middleware, + lifespan=lifespan, + ) + + +@click.command() +@click.option("--port", default=8002, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", +) +@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") +@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +def main( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, +) -> int: + """Run the multi-protocol MCP Resource Server.""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings) + logger.info("Multi-protocol RS running on %s", settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py new file mode 100644 index 000000000..c57a81135 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py @@ -0,0 +1,80 @@ +"""OAuth token verifier using introspection (RFC 7662).""" + +import logging +from typing import Any, cast + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + if not self.introspection_endpoint.startswith( + ("https://", "http://localhost", "http://127.0.0.1") + ): + logger.warning("Rejecting unsafe introspection endpoint") + return None + + timeout = httpx.Timeout(10.0, connect=5.0) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + return None + data = response.json() + if not data.get("active", False): + return None + if self.validate_resource and not self._validate_resource(data): + return None + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), + ) + except Exception as e: + logger.warning("Token introspection failed: %s", e) + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + if not self.server_url or not self.resource_url: + return False + aud = token_data.get("aud") + if isinstance(aud, list): + for item in cast(list[str], aud): + if self._is_valid_resource(item): + return True + return False + if isinstance(aud, str): + return self._is_valid_resource(aud) + return False + + def _is_valid_resource(self, resource: str) -> bool: + if not self.resource_url: + return False + return check_resource_allowed( + requested_resource=self.resource_url, configured_resource=resource + ) diff --git a/examples/servers/simple-auth-multiprotocol/pyproject.toml b/examples/servers/simple-auth-multiprotocol/pyproject.toml new file mode 100644 index 000000000..eaeb20396 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "mcp-simple-auth-multiprotocol" +version = "0.1.0" +description = "MCP Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.2.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-multiprotocol-rs = "mcp_simple_auth_multiprotocol.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_multiprotocol"] + +[dependency-groups] +dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/uv.lock b/uv.lock index 6e0c4596f..6c30f3697 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "mcp-everything-server", "mcp-simple-auth", "mcp-simple-auth-client", + "mcp-simple-auth-multiprotocol", "mcp-simple-chatbot", "mcp-simple-pagination", "mcp-simple-prompt", @@ -927,6 +928,47 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-simple-auth-multiprotocol" +version = "0.1.0" +source = { editable = "examples/servers/simple-auth-multiprotocol" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "ruff", specifier = ">=0.8.5" }, +] + [[package]] name = "mcp-simple-chatbot" version = "0.1.0" From 139d6525ff7852039f86f553106e227c7bbd0fd7 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 17:05:21 +0800 Subject: [PATCH 14/64] feat(examples): add simple-auth-multiprotocol-client and Pyright config for examples/clients - Add examples/clients/simple-auth-multiprotocol-client (API Key + mTLS placeholder) - Pyright: include examples/clients; executionEnvironments + extraPaths for client packages - Fixes import resolution for mcp_simple_auth_multiprotocol_client.main in __main__.py --- .../README.md | 19 ++ .../__init__.py | 1 + .../__main__.py | 6 + .../main.py | 207 ++++++++++++++++++ .../pyproject.toml | 35 +++ pyproject.toml | 9 +- uv.lock | 12 + 7 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 examples/clients/simple-auth-multiprotocol-client/README.md create mode 100644 examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__init__.py create mode 100644 examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__main__.py create mode 100644 examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py create mode 100644 examples/clients/simple-auth-multiprotocol-client/pyproject.toml diff --git a/examples/clients/simple-auth-multiprotocol-client/README.md b/examples/clients/simple-auth-multiprotocol-client/README.md new file mode 100644 index 000000000..3862c415d --- /dev/null +++ b/examples/clients/simple-auth-multiprotocol-client/README.md @@ -0,0 +1,19 @@ +# Simple Auth Multiprotocol Client + +MCP client example using **MultiProtocolAuthProvider** with **API Key** and **Mutual TLS (placeholder)**. + +- Uses `MultiProtocolAuthProvider` and protocol selection from server discovery (PRM / WWW-Authenticate). +- **API Key**: reads key from env `MCP_API_KEY` (default `demo-api-key-12345`), sends `X-API-Key` header. +- **Mutual TLS**: placeholder only; when selected, prints a message and exits (no client cert in this example). + +## Run + +1. Start the multi-protocol resource server (e.g. `simple-auth-multiprotocol` on port 8002). +2. From this directory: `uv run mcp-simple-auth-multiprotocol-client` or `uv run python -m mcp_simple_auth_multiprotocol_client`. +3. Optional: `MCP_SERVER_URL=http://localhost:8002/mcp` to override server URL. + +## Commands + +- `list` – list tools +- `call get_time` – call `get_time` +- `quit` – exit diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__init__.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__init__.py new file mode 100644 index 000000000..bdcf4f17b --- /dev/null +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__init__.py @@ -0,0 +1 @@ +"""Multi-protocol auth client example (API Key + mTLS placeholder).""" diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__main__.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__main__.py new file mode 100644 index 000000000..a374c4cce --- /dev/null +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/__main__.py @@ -0,0 +1,6 @@ +"""Run as python -m mcp_simple_auth_multiprotocol_client.""" + +from mcp_simple_auth_multiprotocol_client.main import cli + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py new file mode 100644 index 000000000..3c3325b0c --- /dev/null +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Multi-protocol MCP client: API Key + Mutual TLS (placeholder).""" + +import asyncio +import os +from typing import Any + +import httpx +from mcp.client.auth.multi_protocol import MultiProtocolAuthProvider, TokenStorage +from mcp.client.auth.protocol import AuthContext, AuthProtocol +from mcp.client.auth.registry import AuthProtocolRegistry +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import ( + APIKeyCredentials, + AuthCredentials, + AuthProtocolMetadata, + OAuthToken, + ProtectedResourceMetadata, +) + + +class InMemoryStorage(TokenStorage): + """In-memory credential storage.""" + + def __init__(self) -> None: + self._creds: AuthCredentials | None = None + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + return self._creds + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + self._creds = tokens if isinstance(tokens, AuthCredentials) else None + + +class ApiKeyProtocol: + """AuthProtocol implementation for API Key (X-API-Key header).""" + + protocol_id = "api_key" + protocol_version = "1.0" + + def __init__(self, api_key: str) -> None: + self._api_key = api_key + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + return APIKeyCredentials(protocol_id=self.protocol_id, api_key=self._api_key) + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + if isinstance(credentials, APIKeyCredentials): + request.headers["X-API-Key"] = credentials.api_key + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return isinstance(credentials, APIKeyCredentials) and bool(credentials.api_key.strip()) + + async def discover_metadata( + self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + return None + + +class MutualTlsPlaceholderProtocol: + """Placeholder for Mutual TLS; when selected, raises (no client cert in this example).""" + + protocol_id = "mutual_tls" + protocol_version = "1.0" + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + raise RuntimeError("Mutual TLS not implemented in this example. Use API Key (set MCP_API_KEY or default).") + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + pass + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return False + + async def discover_metadata( + self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + return None + + +def _register_protocols() -> None: + AuthProtocolRegistry.register("api_key", ApiKeyProtocol) + AuthProtocolRegistry.register("mutual_tls", MutualTlsPlaceholderProtocol) + + +class SimpleAuthMultiprotocolClient: + """MCP client with multi-protocol auth (API Key + mTLS placeholder).""" + + def __init__(self, server_url: str) -> None: + self.server_url = server_url + self.session: ClientSession | None = None + + async def connect(self) -> None: + _register_protocols() + api_key = os.getenv("MCP_API_KEY", "demo-api-key-12345") + storage = InMemoryStorage() + protocols: list[AuthProtocol] = [ + ApiKeyProtocol(api_key=api_key), + MutualTlsPlaceholderProtocol(), + ] + auth = MultiProtocolAuthProvider( + server_url=self.server_url.rstrip("/").replace("/mcp", ""), + storage=storage, + protocols=protocols, + ) + async with httpx.AsyncClient(auth=auth, follow_redirects=True) as http_client: + async with streamable_http_client( + url=self.server_url, + http_client=http_client, + ) as (read_stream, write_stream, get_session_id): + await self._run_session(read_stream, write_stream, get_session_id) + + async def _run_session(self, read_stream: Any, write_stream: Any, get_session_id: Any) -> None: + print("Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + await session.initialize() + print("Session initialized.") + if get_session_id: + sid = get_session_id() + if sid: + print(f"Session ID: {sid}") + await self._interactive_loop() + + async def list_tools(self) -> None: + if not self.session: + print("Not connected.") + return + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\nTools:") + for t in result.tools: + print(f" - {t.name}") + else: + print("No tools.") + except Exception as e: + print(f"List tools failed: {e}") + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> None: + if not self.session: + print("Not connected.") + return + try: + result = await self.session.call_tool(name, arguments or {}) + if hasattr(result, "content"): + for c in result.content: + if getattr(c, "type", None) == "text": + print(getattr(c, "text", c)) + else: + print(c) + else: + print(result) + except Exception as e: + print(f"Call tool failed: {e}") + + async def _interactive_loop(self) -> None: + print("\nCommands: list | call [args] | quit\n") + while True: + try: + line = input("mcp> ").strip() + if not line: + continue + if line == "quit": + break + if line == "list": + await self.list_tools() + elif line.startswith("call "): + parts = line.split(maxsplit=2) + tool = parts[1] if len(parts) > 1 else "" + if not tool: + print("Specify tool name.") + continue + args: dict[str, Any] = {} + if len(parts) > 2: + import json + + try: + args = json.loads(parts[2]) + except json.JSONDecodeError: + pass + await self.call_tool(tool, args) + else: + print("Unknown command.") + except (KeyboardInterrupt, EOFError): + break + print("Bye.") + + +async def main() -> None: + server_url = os.getenv("MCP_SERVER_URL", "http://localhost:8002/mcp") + print(f"Connecting to {server_url}...") + client = SimpleAuthMultiprotocolClient(server_url) + try: + await client.connect() + except Exception as e: + print(f"Failed: {e}") + raise + + +def cli() -> None: + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-multiprotocol-client/pyproject.toml b/examples/clients/simple-auth-multiprotocol-client/pyproject.toml new file mode 100644 index 000000000..78ce66545 --- /dev/null +++ b/examples/clients/simple-auth-multiprotocol-client/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "mcp-simple-auth-multiprotocol-client" +version = "0.1.0" +description = "Multi-protocol auth client (API Key + mTLS placeholder) for MCP" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic" }] +keywords = ["mcp", "auth", "api-key", "client"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["mcp"] + +[project.scripts] +mcp-simple-auth-multiprotocol-client = "mcp_simple_auth_multiprotocol_client.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_multiprotocol_client"] + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" diff --git a/pyproject.toml b/pyproject.toml index 6378fff77..4fdbb48e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,13 +102,7 @@ packages = ["src/mcp"] [tool.pyright] typeCheckingMode = "strict" -include = [ - "src/mcp", - "tests", - "examples/servers", - "examples/snippets", - "examples/clients", -] +include = ["src/mcp", "tests", "examples/servers", "examples/clients", "examples/snippets"] venvPath = "." venv = ".venv" # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. @@ -121,6 +115,7 @@ executionEnvironments = [ ".", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/servers", reportUnusedFunction = false }, + { root = "examples/clients", extraPaths = ["examples/clients/simple-auth-multiprotocol-client", "examples/clients/simple-auth-client", "examples/clients/conformance-auth-client", "examples/clients/simple-chatbot", "examples/clients/simple-task-client", "examples/clients/simple-task-interactive-client", "examples/clients/sse-polling-client"] }, ] [tool.ruff] diff --git a/uv.lock b/uv.lock index 6c30f3697..5c4187d72 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "mcp-simple-auth", "mcp-simple-auth-client", "mcp-simple-auth-multiprotocol", + "mcp-simple-auth-multiprotocol-client", "mcp-simple-chatbot", "mcp-simple-pagination", "mcp-simple-prompt", @@ -969,6 +970,17 @@ dev = [ { name = "ruff", specifier = ">=0.8.5" }, ] +[[package]] +name = "mcp-simple-auth-multiprotocol-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-multiprotocol-client" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", editable = "." }] + [[package]] name = "mcp-simple-chatbot" version = "0.1.0" From 3d2241427107cd28c391d06b054969227e2a69e9 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 17:26:38 +0800 Subject: [PATCH 15/64] fix(auth): API Key scope for RequireAuthMiddleware; add phase2 multiprotocol integration test script - APIKeyVerifier: optional scopes param for AccessToken (satisfy required_scopes) - simple-auth-multiprotocol: build_multiprotocol_backend(api_key_scopes=[mcp_scope]) - scripts/run_phase2_multiprotocol_integration_test.sh: api_key (default), oauth (AS+RS+simple-auth-client), mutual_tls (placeholder) --- .../multiprotocol.py | 6 +- .../mcp_simple_auth_multiprotocol/server.py | 4 +- ...n_phase2_multiprotocol_integration_test.sh | 94 +++++++++++++++++++ src/mcp/server/auth/verifiers.py | 6 +- 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100755 scripts/run_phase2_multiprotocol_integration_test.sh diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py index 5e4b0e3d0..11e22150a 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py @@ -35,10 +35,14 @@ async def verify( def build_multiprotocol_backend( oauth_token_verifier: Any, api_key_valid_keys: set[str], + api_key_scopes: list[str] | None = None, ) -> MultiProtocolAuthBackend: """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) - api_key_verifier = APIKeyVerifier(valid_keys=api_key_valid_keys) + api_key_verifier = APIKeyVerifier( + valid_keys=api_key_valid_keys, + scopes=api_key_scopes or [], + ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() return MultiProtocolAuthBackend( verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py index aa3199e20..03c92ebb1 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -92,7 +92,9 @@ def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> St validate_resource=settings.oauth_strict, ) api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend = build_multiprotocol_backend(oauth_verifier, api_key_keys) + backend = build_multiprotocol_backend( + oauth_verifier, api_key_keys, api_key_scopes=[settings.mcp_scope] + ) adapter = MultiProtocolAuthBackendAdapter(backend) fastmcp = FastMCP( diff --git a/scripts/run_phase2_multiprotocol_integration_test.sh b/scripts/run_phase2_multiprotocol_integration_test.sh new file mode 100755 index 000000000..860b21406 --- /dev/null +++ b/scripts/run_phase2_multiprotocol_integration_test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Phase 2 multi-protocol integration test: start simple-auth-multiprotocol RS (and optionally AS for OAuth), +# then run client with API Key, OAuth, or Mutual TLS (placeholder). +# Usage: from repo root, run: ./scripts/run_phase2_multiprotocol_integration_test.sh +# Env: MCP_PHASE2_PROTOCOL=api_key (default) | oauth | mutual_tls (client will show "not implemented" for mTLS). +# For api_key/mutual_tls: simple-auth-multiprotocol-client; for oauth: simple-auth-client (complete OAuth in browser). +# You must run at mcp> prompt: list, call get_time {}, quit. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" +MULTIPROTOCOL_SERVER="${REPO_ROOT}/examples/servers/simple-auth-multiprotocol" +MULTIPROTOCOL_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-multiprotocol-client" +SIMPLE_AUTH_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-client" +RS_PORT="${MCP_RS_PORT:-8002}" +AS_PORT="${MCP_AS_PORT:-9000}" +PROTOCOL="${MCP_PHASE2_PROTOCOL:-api_key}" + +cd "$REPO_ROOT" +echo "Repo root: $REPO_ROOT" +echo "Protocol: $PROTOCOL" + +uv sync --quiet 2>/dev/null || true + +wait_for_url() { + local url="$1" + local name="$2" + local max=30 + local n=0 + while ! curl -sSf -o /dev/null "$url" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -ge "$max" ]; then + echo "Timeout waiting for $name at $url" + return 1 + fi + sleep 0.5 + done + echo "$name is up at $url" +} + +cleanup() { + echo "Stopping servers..." + [ -n "$AS_PID" ] && kill "$AS_PID" 2>/dev/null || true + [ -n "$RS_PID" ] && kill "$RS_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT + +# Start Authorization Server only for OAuth +if [ "$PROTOCOL" = "oauth" ]; then + cd "$SIMPLE_AUTH_SERVER" + uv run mcp-simple-auth-as --port="$AS_PORT" & + AS_PID=$! + cd "$REPO_ROOT" + wait_for_url "http://localhost:${AS_PORT}/.well-known/oauth-authorization-server" "Authorization Server" +fi + +# Start multi-protocol Resource Server +cd "$MULTIPROTOCOL_SERVER" +if [ "$PROTOCOL" = "oauth" ]; then + uv run mcp-simple-auth-multiprotocol-rs --port="$RS_PORT" --auth-server="http://localhost:${AS_PORT}" --api-keys="demo-api-key-12345" & +else + uv run mcp-simple-auth-multiprotocol-rs --port="$RS_PORT" --api-keys="demo-api-key-12345" & +fi +RS_PID=$! +cd "$REPO_ROOT" + +wait_for_url "http://localhost:${RS_PORT}/.well-known/oauth-protected-resource/mcp" "Multi-protocol RS (PRM)" + +echo "" +echo "PRM (auth_protocols etc.):" +curl -sS "http://localhost:${RS_PORT}/.well-known/oauth-protected-resource/mcp" | head -c 600 +echo "" +echo "" + +# Run client by protocol +if [ "$PROTOCOL" = "oauth" ]; then + echo "Starting simple-auth-client (OAuth). Complete OAuth in the browser, then run: list, call get_time {}, quit" + echo "" + cd "$SIMPLE_AUTH_CLIENT" + MCP_SERVER_PORT="$RS_PORT" MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +elif [ "$PROTOCOL" = "mutual_tls" ]; then + echo "Starting simple-auth-multiprotocol-client (mTLS placeholder). At mcp> run: list, call get_time {}, quit" + echo "" + cd "$MULTIPROTOCOL_CLIENT" + unset MCP_API_KEY + MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" uv run mcp-simple-auth-multiprotocol-client +else + echo "Starting simple-auth-multiprotocol-client (API Key). At mcp> run: list, call get_time {}, quit" + echo "" + cd "$MULTIPROTOCOL_CLIENT" + MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" MCP_API_KEY="demo-api-key-12345" uv run mcp-simple-auth-multiprotocol-client +fi diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py index b39e06d0b..e4d210282 100644 --- a/src/mcp/server/auth/verifiers.py +++ b/src/mcp/server/auth/verifiers.py @@ -76,10 +76,12 @@ class APIKeyVerifier: 优先从 X-API-Key header 读取;可选从 Authorization: Bearer 读取并在 valid_keys 中查找。 不解析非标准 ApiKey scheme;DPoP 占位,阶段4 再实现。 + 可选 scopes:校验通过时赋予的 scope 列表,用于满足 RequireAuthMiddleware 的 required_scopes。 """ - def __init__(self, valid_keys: set[str]) -> None: + def __init__(self, valid_keys: set[str], scopes: list[str] | None = None) -> None: self._valid_keys = valid_keys + self._scopes = scopes if scopes is not None else [] async def verify( self, @@ -98,7 +100,7 @@ async def verify( return AccessToken( token=api_key, client_id="api_key", - scopes=[], + scopes=list(self._scopes), expires_at=None, ) From 9f3c7c377885cd5df8836e7e9a0c3470bfb6af32 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 23:13:33 +0800 Subject: [PATCH 16/64] feat(auth): add OAuth2Protocol thin adapter and run_authentication, align resource_metadata_url with 401 branch --- src/mcp/client/auth/multi_protocol.py | 5 + src/mcp/client/auth/oauth2.py | 89 ++++++++++ src/mcp/client/auth/protocol.py | 5 + src/mcp/client/auth/protocols/__init__.py | 5 + src/mcp/client/auth/protocols/oauth2.py | 122 ++++++++++++++ tests/client/auth/test_oauth2_protocol.py | 188 ++++++++++++++++++++++ 6 files changed, 414 insertions(+) create mode 100644 src/mcp/client/auth/protocols/__init__.py create mode 100644 src/mcp/client/auth/protocols/oauth2.py create mode 100644 tests/client/auth/test_oauth2_protocol.py diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index 9673b9004..b630f523e 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -22,6 +22,7 @@ extract_field_from_www_auth, extract_protocol_preferences_from_www_auth, extract_resource_metadata_from_www_auth, + extract_scope_from_www_auth, handle_protected_resource_response, ) from mcp.shared.auth import ( @@ -233,6 +234,10 @@ async def _discover_and_authenticate( current_credentials=None, dpop_storage=self.dpop_storage, dpop_enabled=self.dpop_enabled, + http_client=self._http_client, + resource_metadata_url=resource_metadata_url, + protected_resource_metadata=prm, + scope_from_www_auth=extract_scope_from_www_auth(response), ) credentials = await protocol.authenticate(context) to_store = _credentials_to_storage(credentials) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 98df4d25d..597284c36 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -460,6 +460,95 @@ async def _handle_refresh_response(self, response: httpx.Response) -> bool: # p self.context.clear_tokens() return False + async def run_authentication( + self, + http_client: httpx.AsyncClient, + *, + resource_metadata_url: str | None = None, + scope_from_www_auth: str | None = None, + protocol_version: str | None = None, + protected_resource_metadata: ProtectedResourceMetadata | None = None, + ) -> None: + """ + 使用给定 http_client 执行完整 OAuth 流程(PRM/ASM 发现、scope、注册或 CIMD、授权码+令牌交换), + 与现有 401 分支行为一致。供多协议路径下 OAuth2Protocol 调用。 + """ + self.context.protocol_version = protocol_version + if protected_resource_metadata is not None: + self.context.protected_resource_metadata = protected_resource_metadata + if protected_resource_metadata.authorization_servers: + self.context.auth_server_url = str(protected_resource_metadata.authorization_servers[0]) + + if not self.context.protected_resource_metadata or not self.context.auth_server_url: + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( + resource_metadata_url, self.context.server_url + ) + for url in prm_discovery_urls: + try: + discovery_request = create_oauth_metadata_request(url) + discovery_response = await http_client.send(discovery_request) + prm = await handle_protected_resource_response(discovery_response) + if prm: + self.context.protected_resource_metadata = prm + if prm.authorization_servers: + self.context.auth_server_url = str(prm.authorization_servers[0]) + break + except Exception as e: + logger.debug("PRM discovery failed for %s: %s", url, e) + + if not self.context.auth_server_url: + raise OAuthFlowError("Could not discover authorization server") + + if not self.context.oauth_metadata: + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url + ) + for url in asm_discovery_urls: + try: + oauth_metadata_request = create_oauth_metadata_request(url) + oauth_metadata_response = await http_client.send(oauth_metadata_request) + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if ok and asm: + self.context.oauth_metadata = asm + break + except Exception as e: + logger.debug("OAuth metadata discovery failed for %s: %s", url, e) + + if not self.context.oauth_metadata: + raise OAuthFlowError("Could not discover OAuth metadata") + + self.context.client_metadata.scope = get_client_metadata_scopes( + scope_from_www_auth, + self.context.protected_resource_metadata, + self.context.oauth_metadata, + ) + + if not self.context.client_info: + if should_use_client_metadata_url( + self.context.oauth_metadata, 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: + registration_request = create_client_registration_request( + self.context.oauth_metadata, + self.context.client_metadata, + self.context.get_authorization_base_url(self.context.server_url), + ) + registration_response = await http_client.send(registration_request) + client_information = await handle_registration_response(registration_response) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + + auth_code, code_verifier = await self._perform_authorization_code_grant() + token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + token_response = await http_client.send(token_request) + await self._handle_token_response(token_response) + async def _initialize(self) -> None: # pragma: no cover """Load stored tokens and client info.""" self.context.current_tokens = await self.context.storage.get_tokens() diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py index ab67e723f..d9c393a6f 100644 --- a/src/mcp/client/auth/protocol.py +++ b/src/mcp/client/auth/protocol.py @@ -45,6 +45,11 @@ class AuthContext: current_credentials: AuthCredentials | None = None dpop_storage: DPoPStorage | None = None dpop_enabled: bool = False + # 供 OAuth2Protocol.run_authentication 使用(多协议路径,与 401 分支一致) + http_client: httpx.AsyncClient | None = None + resource_metadata_url: str | None = None + protected_resource_metadata: ProtectedResourceMetadata | None = None + scope_from_www_auth: str | None = None class AuthProtocol(Protocol): diff --git a/src/mcp/client/auth/protocols/__init__.py b/src/mcp/client/auth/protocols/__init__.py new file mode 100644 index 000000000..528d03057 --- /dev/null +++ b/src/mcp/client/auth/protocols/__init__.py @@ -0,0 +1,5 @@ +"""协议实现包。""" + +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol + +__all__ = ["OAuth2Protocol"] diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py new file mode 100644 index 000000000..fb5516f9f --- /dev/null +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -0,0 +1,122 @@ +""" +OAuth 2.0 协议薄适配层。 + +不迁移 OAuth 发现/注册/授权码/令牌交换逻辑到此文件; +authenticate(context) 构造 OAuthClientProvider、填充上下文后调用 +provider.run_authentication(context.http_client, ...),返回 OAuthCredentials。 +""" + +import time +from collections.abc import Awaitable, Callable + +import httpx + +from mcp.client.auth.oauth2 import OAuthClientProvider +from mcp.client.auth.protocol import AuthContext +from mcp.shared.auth import ( + AuthCredentials, + AuthProtocolMetadata, + OAuthClientMetadata, + OAuthCredentials, + OAuthToken, + ProtectedResourceMetadata, +) + + +def _token_to_oauth_credentials(token: OAuthToken) -> OAuthCredentials: + """将 OAuthToken 转为 OAuthCredentials。""" + from mcp.shared.auth_utils import calculate_token_expiry + + expires_at: int | None = None + if token.expires_in is not None: + expiry = calculate_token_expiry(token.expires_in) + expires_at = int(expiry) if expiry is not None else None + return OAuthCredentials.model_validate( + { + "protocol_id": "oauth2", + "access_token": token.access_token, + "token_type": token.token_type, + "refresh_token": token.refresh_token, + "scope": token.scope, + "expires_at": expires_at, + } + ) + + +class OAuth2Protocol: + """ + OAuth 2.0 协议薄适配层。 + + 实现 AuthProtocol,authenticate 委托 OAuthClientProvider.run_authentication, + 不重复实现 OAuth 流程。 + """ + + protocol_id: str = "oauth2" + protocol_version: str = "1.0" + + def __init__( + self, + client_metadata: OAuthClientMetadata, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, + timeout: float = 300.0, + client_metadata_url: str | None = None, + ): + self._client_metadata = client_metadata + self._redirect_handler = redirect_handler + self._callback_handler = callback_handler + self._timeout = timeout + self._client_metadata_url = client_metadata_url + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + """从 AuthContext 组装 OAuth 上下文,委托 OAuthClientProvider.run_authentication,返回 OAuthCredentials。""" + if context.http_client is None: + raise ValueError("OAuth2Protocol.authenticate requires context.http_client") + + provider = OAuthClientProvider( + server_url=context.server_url, + client_metadata=self._client_metadata, + storage=context.storage, + redirect_handler=self._redirect_handler, + callback_handler=self._callback_handler, + timeout=self._timeout, + client_metadata_url=self._client_metadata_url, + ) + protocol_version: str | None = None + if context.protocol_metadata is not None: + protocol_version = getattr( + context.protocol_metadata, "protocol_version", None + ) + await provider.run_authentication( + context.http_client, + resource_metadata_url=context.resource_metadata_url, + scope_from_www_auth=context.scope_from_www_auth, + protocol_version=protocol_version, + protected_resource_metadata=context.protected_resource_metadata, + ) + if not provider.context.current_tokens: + raise RuntimeError("run_authentication completed but no tokens in provider") + return _token_to_oauth_credentials(provider.context.current_tokens) + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + """为请求添加 Bearer 认证头。""" + if isinstance(credentials, OAuthCredentials) and credentials.access_token: + request.headers["Authorization"] = f"Bearer {credentials.access_token}" + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + """验证 OAuth 凭证是否有效(未过期等)。""" + if not isinstance(credentials, OAuthCredentials): + return False + if not credentials.access_token: + return False + if credentials.expires_at is not None and credentials.expires_at <= int(time.time()): + return False + return True + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + ) -> AuthProtocolMetadata | None: + """发现协议元数据(RFC 8414)。TODO 23 中完善。""" + return None diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py new file mode 100644 index 000000000..b6cdcc41c --- /dev/null +++ b/tests/client/auth/test_oauth2_protocol.py @@ -0,0 +1,188 @@ +"""单元测试:OAuth2Protocol 薄适配层(authenticate 委托 run_authentication、prepare_request、validate_credentials、discover_metadata)。""" + +import httpx +import pytest + +from mcp.client.auth.protocol import AuthContext +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol +from mcp.shared.auth import ( + AuthCredentials, + OAuthClientMetadata, + OAuthCredentials, + OAuthToken, +) + + +@pytest.fixture +def client_metadata() -> OAuthClientMetadata: + from pydantic import AnyUrl + + return OAuthClientMetadata( + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + grant_types=["authorization_code"], + scope="read write", + ) + + +@pytest.fixture +def oauth2_protocol(client_metadata: OAuthClientMetadata) -> OAuth2Protocol: + return OAuth2Protocol( + client_metadata=client_metadata, + redirect_handler=None, + callback_handler=None, + timeout=60.0, + ) + + +def test_oauth2_protocol_id_and_version(oauth2_protocol: OAuth2Protocol) -> None: + assert oauth2_protocol.protocol_id == "oauth2" + assert oauth2_protocol.protocol_version == "1.0" + + +def test_prepare_request_sets_bearer_header(oauth2_protocol: OAuth2Protocol) -> None: + request = httpx.Request("GET", "https://example.com/") + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="test-token", + token_type="Bearer", + ) + oauth2_protocol.prepare_request(request, creds) + assert request.headers.get("Authorization") == "Bearer test-token" + + +def test_prepare_request_no_op_when_no_access_token( + oauth2_protocol: OAuth2Protocol, +) -> None: + request = httpx.Request("GET", "https://example.com/") + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="", + token_type="Bearer", + ) + oauth2_protocol.prepare_request(request, creds) + assert "Authorization" not in request.headers + + +def test_validate_credentials_returns_true_for_valid_oauth_creds( + oauth2_protocol: OAuth2Protocol, +) -> None: + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + token_type="Bearer", + expires_at=None, + ) + assert oauth2_protocol.validate_credentials(creds) is True + + +def test_validate_credentials_returns_false_when_expired( + oauth2_protocol: OAuth2Protocol, +) -> None: + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + token_type="Bearer", + expires_at=1, + ) + assert oauth2_protocol.validate_credentials(creds) is False + + +def test_validate_credentials_returns_false_for_non_oauth( + oauth2_protocol: OAuth2Protocol, +) -> None: + creds = AuthCredentials(protocol_id="api_key", expires_at=None) + assert oauth2_protocol.validate_credentials(creds) is False + + +def test_validate_credentials_returns_false_when_no_token( + oauth2_protocol: OAuth2Protocol, +) -> None: + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="", + token_type="Bearer", + ) + assert oauth2_protocol.validate_credentials(creds) is False + + +@pytest.mark.anyio +async def test_discover_metadata_returns_none(oauth2_protocol: OAuth2Protocol) -> None: + result = await oauth2_protocol.discover_metadata( + metadata_url="https://example.com/.well-known/oauth-authorization-server", + prm=None, + ) + assert result is None + + +@pytest.mark.anyio +async def test_authenticate_requires_http_client( + oauth2_protocol: OAuth2Protocol, + client_metadata: OAuthClientMetadata, +) -> None: + context = AuthContext( + server_url="https://example.com", + storage=None, + protocol_id="oauth2", + protocol_metadata=None, + current_credentials=None, + dpop_storage=None, + dpop_enabled=False, + http_client=None, + protected_resource_metadata=None, + scope_from_www_auth=None, + ) + with pytest.raises(ValueError, match="context.http_client"): + await oauth2_protocol.authenticate(context) + + +@pytest.mark.anyio +async def test_authenticate_delegates_to_run_authentication_and_returns_oauth_credentials( + oauth2_protocol: OAuth2Protocol, + client_metadata: OAuthClientMetadata, +) -> None: + """authenticate(context) 调用 provider.run_authentication 并从 current_tokens 转为 OAuthCredentials。""" + from unittest.mock import AsyncMock, MagicMock, patch + + mock_storage = MagicMock() + mock_storage.get_tokens = AsyncMock(return_value=None) + mock_storage.get_client_info = AsyncMock(return_value=None) + mock_storage.set_tokens = AsyncMock() + mock_storage.set_client_info = AsyncMock() + + token_after_run = OAuthToken( + access_token="returned-token", + token_type="Bearer", + expires_in=3600, + scope="read", + refresh_token="rt", + ) + mock_provider = MagicMock() + mock_provider.context = MagicMock() + mock_provider.context.current_tokens = token_after_run + mock_provider.run_authentication = AsyncMock() + + async with httpx.AsyncClient() as http_client: + with patch( + "mcp.client.auth.protocols.oauth2.OAuthClientProvider", + return_value=mock_provider, + ): + creds = await oauth2_protocol.authenticate( + AuthContext( + server_url="https://example.com", + storage=mock_storage, + protocol_id="oauth2", + protocol_metadata=None, + current_credentials=None, + dpop_storage=None, + dpop_enabled=False, + http_client=http_client, + protected_resource_metadata=None, + scope_from_www_auth=None, + ) + ) + mock_provider.run_authentication.assert_called_once() + assert isinstance(creds, OAuthCredentials) + assert creds.protocol_id == "oauth2" + assert creds.access_token == "returned-token" + assert creds.scope == "read" + assert creds.refresh_token == "rt" From d87e8cc6ab4c2f246739c60f6d2f5141d9401499 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 23:37:45 +0800 Subject: [PATCH 17/64] feat(auth): implement OAuth2Protocol.discover_metadata (RFC 8414), add optional http_client to discover_metadata --- .../main.py | 10 +- src/mcp/client/auth/protocol.py | 6 +- src/mcp/client/auth/protocols/oauth2.py | 77 ++++++- src/mcp/shared/auth.py | 26 +++ tests/client/auth/test_oauth2_protocol.py | 36 +++- tests/client/test_multi_protocol_provider.py | 199 ++++++++++++++++++ tests/client/test_registry.py | 150 +++++++++++++ 7 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 tests/client/test_multi_protocol_provider.py create mode 100644 tests/client/test_registry.py diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py index 3c3325b0c..176efae41 100644 --- a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -53,7 +53,10 @@ def validate_credentials(self, credentials: AuthCredentials) -> bool: return isinstance(credentials, APIKeyCredentials) and bool(credentials.api_key.strip()) async def discover_metadata( - self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: return None @@ -74,7 +77,10 @@ def validate_credentials(self, credentials: AuthCredentials) -> bool: return False async def discover_metadata( - self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: return None diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py index d9c393a6f..d2a933d47 100644 --- a/src/mcp/client/auth/protocol.py +++ b/src/mcp/client/auth/protocol.py @@ -93,7 +93,10 @@ def validate_credentials(self, credentials: AuthCredentials) -> bool: ... async def discover_metadata( - self, metadata_url: str | None, prm: ProtectedResourceMetadata | None = None + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: """ 发现协议元数据。 @@ -101,6 +104,7 @@ async def discover_metadata( Args: metadata_url: 元数据URL(可选) prm: 受保护资源元数据(可选) + http_client: 可选 HTTP 客户端,用于执行 RFC 8414 等网络发现 Returns: 协议元数据,如果发现失败则返回None diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py index fb5516f9f..0b5cd07a4 100644 --- a/src/mcp/client/auth/protocols/oauth2.py +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -4,8 +4,10 @@ 不迁移 OAuth 发现/注册/授权码/令牌交换逻辑到此文件; authenticate(context) 构造 OAuthClientProvider、填充上下文后调用 provider.run_authentication(context.http_client, ...),返回 OAuthCredentials。 +discover_metadata 在提供 http_client 时执行 RFC 8414 授权服务器元数据发现。 """ +import logging import time from collections.abc import Awaitable, Callable @@ -13,15 +15,50 @@ from mcp.client.auth.oauth2 import OAuthClientProvider from mcp.client.auth.protocol import AuthContext +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + create_oauth_metadata_request, + handle_auth_metadata_response, +) +from pydantic import AnyHttpUrl + from mcp.shared.auth import ( AuthCredentials, AuthProtocolMetadata, OAuthClientMetadata, OAuthCredentials, + OAuthMetadata, OAuthToken, ProtectedResourceMetadata, ) +logger = logging.getLogger(__name__) + + +def _oauth_metadata_to_protocol_metadata(asm: OAuthMetadata) -> AuthProtocolMetadata: + """将 RFC 8414 OAuth 授权服务器元数据转换为 AuthProtocolMetadata。""" + endpoints: dict[str, AnyHttpUrl] = { + "authorization_endpoint": asm.authorization_endpoint, + "token_endpoint": asm.token_endpoint, + } + + if asm.registration_endpoint is not None: + endpoints["registration_endpoint"] = asm.registration_endpoint + if asm.revocation_endpoint is not None: + endpoints["revocation_endpoint"] = asm.revocation_endpoint + if asm.introspection_endpoint is not None: + endpoints["introspection_endpoint"] = asm.introspection_endpoint + + return AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=asm.issuer, + endpoints=endpoints, + scopes_supported=asm.scopes_supported, + grant_types=asm.grant_types_supported, + client_auth_methods=asm.token_endpoint_auth_methods_supported, + ) + def _token_to_oauth_credentials(token: OAuthToken) -> OAuthCredentials: """将 OAuthToken 转为 OAuthCredentials。""" @@ -117,6 +154,44 @@ async def discover_metadata( self, metadata_url: str | None = None, prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: - """发现协议元数据(RFC 8414)。TODO 23 中完善。""" + """ + 发现 OAuth 2.0 协议元数据(RFC 8414)。 + + 若 prm 中已有 oauth2 的 mcp_auth_protocols 条目则直接返回; + 若提供 http_client 且存在 metadata_url 或 prm.authorization_servers, + 则按 RFC 8414 请求授权服务器元数据并转换为 AuthProtocolMetadata。 + """ + if prm is not None and prm.mcp_auth_protocols: + for m in prm.mcp_auth_protocols: + if m.protocol_id == "oauth2": + return m + + auth_server_url: str | None = metadata_url + server_url_for_discovery: str = "" + if prm is not None: + if not auth_server_url and prm.authorization_servers: + auth_server_url = str(prm.authorization_servers[0]) + server_url_for_discovery = str(prm.resource) + if auth_server_url and not server_url_for_discovery: + server_url_for_discovery = auth_server_url + + if not http_client or not auth_server_url: + return None + + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + auth_server_url, server_url_for_discovery + ) + for url in discovery_urls: + try: + req = create_oauth_metadata_request(url) + resp = await http_client.send(req) + ok, asm = await handle_auth_metadata_response(resp) + if not ok: + break + if asm is not None: + return _oauth_metadata_to_protocol_metadata(asm) + except Exception as e: + logger.debug("OAuth AS metadata discovery failed for %s: %s", url, e) return None diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 67692004a..ebf2ef842 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -22,6 +22,32 @@ def normalize_token_type(cls, v: str | None) -> str | None: return v # pragma: no cover +class AuthCredentials(BaseModel): + """Generic authentication credentials for multi-protocol auth.""" + + protocol_id: str + expires_at: int | None = None + + +class OAuthCredentials(AuthCredentials): + """OAuth credentials (multi-protocol wrapper).""" + + protocol_id: str = "oauth2" + access_token: str + token_type: Literal["Bearer"] = "Bearer" + refresh_token: str | None = None + scope: str | None = None + cnf: dict[str, Any] | None = None # DPoP confirmation / binding + + +class APIKeyCredentials(AuthCredentials): + """API key credentials (multi-protocol wrapper).""" + + protocol_id: str = "api_key" + api_key: str + key_id: str | None = None + + class InvalidScopeError(Exception): def __init__(self, message: str): self.message = message diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index b6cdcc41c..2c7dca96f 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -7,9 +7,11 @@ from mcp.client.auth.protocols.oauth2 import OAuth2Protocol from mcp.shared.auth import ( AuthCredentials, + AuthProtocolMetadata, OAuthClientMetadata, OAuthCredentials, OAuthToken, + ProtectedResourceMetadata, ) @@ -106,7 +108,10 @@ def test_validate_credentials_returns_false_when_no_token( @pytest.mark.anyio -async def test_discover_metadata_returns_none(oauth2_protocol: OAuth2Protocol) -> None: +async def test_discover_metadata_returns_none_without_http_client( + oauth2_protocol: OAuth2Protocol, +) -> None: + """无 http_client 且无 prm 或 prm 无 oauth2 时,不发起网络请求,返回 None。""" result = await oauth2_protocol.discover_metadata( metadata_url="https://example.com/.well-known/oauth-authorization-server", prm=None, @@ -114,6 +119,35 @@ async def test_discover_metadata_returns_none(oauth2_protocol: OAuth2Protocol) - assert result is None +@pytest.mark.anyio +async def test_discover_metadata_from_prm_returns_oauth2_entry( + oauth2_protocol: OAuth2Protocol, +) -> None: + """当 prm.mcp_auth_protocols 含 oauth2 时,直接返回该条目,无需 http_client。""" + from pydantic import AnyHttpUrl + + oauth2_meta = AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=AnyHttpUrl("https://as.example/"), + endpoints={"authorization_endpoint": AnyHttpUrl("https://as.example/authorize")}, + ) + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://rs.example/"), + authorization_servers=[AnyHttpUrl("https://as.example/")], + mcp_auth_protocols=[oauth2_meta], + ) + result = await oauth2_protocol.discover_metadata( + metadata_url=None, + prm=prm, + ) + assert result is not None + assert result.protocol_id == "oauth2" + assert result.protocol_version == "2.0" + assert result.metadata_url is not None + assert str(result.metadata_url) == "https://as.example/" + + @pytest.mark.anyio async def test_authenticate_requires_http_client( oauth2_protocol: OAuth2Protocol, diff --git a/tests/client/test_multi_protocol_provider.py b/tests/client/test_multi_protocol_provider.py new file mode 100644 index 000000000..08c85259f --- /dev/null +++ b/tests/client/test_multi_protocol_provider.py @@ -0,0 +1,199 @@ +"""Regression tests for MultiProtocolAuthProvider and credential helpers.""" + +import httpx +import pytest + +from mcp.client.auth.multi_protocol import ( + MultiProtocolAuthProvider, + TokenStorage, + _credentials_to_storage, + _oauth_token_to_credentials, +) +from mcp.client.auth.protocol import AuthContext +from mcp.shared.auth import ( + APIKeyCredentials, + AuthCredentials, + AuthProtocolMetadata, + OAuthCredentials, + OAuthToken, + ProtectedResourceMetadata, +) + + +class _MockStorage(TokenStorage): + def __init__(self) -> None: + self._tokens: AuthCredentials | OAuthToken | None = None + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + self._tokens = tokens + + +class _MockProtocol: + protocol_id = "test_proto" + protocol_version = "1.0" + _prepare_called = False + _validate_return = True + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + return AuthCredentials(protocol_id="test_proto") + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + _MockProtocol._prepare_called = True + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return _MockProtocol._validate_return + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + +@pytest.fixture +def mock_storage() -> _MockStorage: + return _MockStorage() + + +@pytest.fixture +def mock_protocol() -> _MockProtocol: + _MockProtocol._prepare_called = False + _MockProtocol._validate_return = True + return _MockProtocol() + + +@pytest.fixture +def provider(mock_storage: _MockStorage, mock_protocol: _MockProtocol) -> MultiProtocolAuthProvider: + return MultiProtocolAuthProvider( + server_url="https://example.com", + storage=mock_storage, + protocols=[mock_protocol], + ) + + +def test_oauth_token_to_credentials() -> None: + token = OAuthToken( + access_token="at", + token_type="Bearer", + expires_in=3600, + scope="read", + refresh_token="rt", + ) + creds = _oauth_token_to_credentials(token) + assert isinstance(creds, OAuthCredentials) + assert creds.protocol_id == "oauth2" + assert creds.access_token == "at" + assert creds.refresh_token == "rt" + assert creds.scope == "read" + + +def test_credentials_to_storage_oauth_returns_oauth_token() -> None: + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + refresh_token="rt", + scope="read", + ) + out = _credentials_to_storage(creds) + assert isinstance(out, OAuthToken) + assert out.access_token == "at" + assert out.refresh_token == "rt" + assert out.scope == "read" + + +def test_credentials_to_storage_api_key_returns_unchanged() -> None: + creds = APIKeyCredentials(protocol_id="api_key", api_key="key1") + out = _credentials_to_storage(creds) + assert out is creds + + +def test_provider_initialize_builds_protocol_index(provider: MultiProtocolAuthProvider) -> None: + provider._initialize() + assert provider._initialized + assert provider._get_protocol("test_proto") is not None + assert provider._get_protocol("other") is None + + +@pytest.mark.anyio +async def test_get_credentials_returns_none_when_storage_empty( + provider: MultiProtocolAuthProvider, +) -> None: + creds = await provider._get_credentials() + assert creds is None + + +@pytest.mark.anyio +async def test_get_credentials_returns_auth_credentials_from_storage( + provider: MultiProtocolAuthProvider, + mock_storage: _MockStorage, +) -> None: + raw = AuthCredentials(protocol_id="test_proto") + mock_storage._tokens = raw + creds = await provider._get_credentials() + assert creds is raw + + +@pytest.mark.anyio +async def test_get_credentials_converts_oauth_token_from_storage( + provider: MultiProtocolAuthProvider, + mock_storage: _MockStorage, +) -> None: + mock_storage._tokens = OAuthToken( + access_token="at", + token_type="Bearer", + expires_in=3600, + ) + creds = await provider._get_credentials() + assert isinstance(creds, OAuthCredentials) + assert creds.access_token == "at" + + +def test_is_credentials_valid_false_when_none(provider: MultiProtocolAuthProvider) -> None: + provider._initialize() + assert provider._is_credentials_valid(None) is False + + +def test_is_credentials_valid_false_when_protocol_unknown( + provider: MultiProtocolAuthProvider, +) -> None: + provider._initialize() + creds = AuthCredentials(protocol_id="unknown_proto") + assert provider._is_credentials_valid(creds) is False + + +def test_is_credentials_valid_delegates_to_protocol( + provider: MultiProtocolAuthProvider, + mock_protocol: _MockProtocol, +) -> None: + provider._initialize() + creds = AuthCredentials(protocol_id="test_proto") + assert provider._is_credentials_valid(creds) is True + _MockProtocol._validate_return = False + assert provider._is_credentials_valid(creds) is False + + +def test_prepare_request_calls_protocol( + provider: MultiProtocolAuthProvider, + mock_protocol: _MockProtocol, +) -> None: + provider._initialize() + request = httpx.Request("GET", "https://example.com/") + creds = AuthCredentials(protocol_id="test_proto") + provider._prepare_request(request, creds) + assert _MockProtocol._prepare_called + + +def test_prepare_request_no_op_when_protocol_missing( + provider: MultiProtocolAuthProvider, +) -> None: + _MockProtocol._prepare_called = False + provider._initialize() + request = httpx.Request("GET", "https://example.com/") + creds = AuthCredentials(protocol_id="other") + provider._prepare_request(request, creds) + assert _MockProtocol._prepare_called is False diff --git a/tests/client/test_registry.py b/tests/client/test_registry.py new file mode 100644 index 000000000..9bab8cae0 --- /dev/null +++ b/tests/client/test_registry.py @@ -0,0 +1,150 @@ +"""Regression tests for AuthProtocolRegistry.""" + +import httpx +import pytest + +from mcp.client.auth.protocol import AuthContext +from mcp.client.auth.registry import AuthProtocolRegistry +from mcp.shared.auth import AuthCredentials, AuthProtocolMetadata, ProtectedResourceMetadata + + +class _MockAuthProtocol: + """Minimal AuthProtocol implementation for registry tests.""" + + protocol_id = "mock" + protocol_version = "1.0" + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + return AuthCredentials(protocol_id="mock") + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + pass + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return True + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + +class _MockOAuth2Protocol: + protocol_id = "oauth2" + protocol_version = "2.0" + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + raise NotImplementedError + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + pass + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return True + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + +class _MockApiKeyProtocol: + protocol_id = "api_key" + protocol_version = "1.0" + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + raise NotImplementedError + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + pass + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return True + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + +@pytest.fixture(autouse=True) +def _reset_registry(): + """Reset registry state before and after each test.""" + before = dict(AuthProtocolRegistry._protocols) + yield + AuthProtocolRegistry._protocols.clear() + AuthProtocolRegistry._protocols.update(before) + + +def test_register_and_get_protocol_class(): + AuthProtocolRegistry.register("mock", _MockAuthProtocol) + assert AuthProtocolRegistry.get_protocol_class("mock") is _MockAuthProtocol + assert AuthProtocolRegistry.get_protocol_class("nonexistent") is None + + +def test_list_registered(): + assert AuthProtocolRegistry.list_registered() == [] + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + registered = AuthProtocolRegistry.list_registered() + assert set(registered) == {"oauth2", "api_key"} + + +def test_select_protocol_returns_none_when_no_support(): + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + assert AuthProtocolRegistry.select_protocol(["api_key", "mutual_tls"]) is None + + +def test_select_protocol_returns_first_supported(): + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + assert AuthProtocolRegistry.select_protocol(["api_key", "oauth2"]) == "api_key" + assert AuthProtocolRegistry.select_protocol(["oauth2", "api_key"]) == "oauth2" + + +def test_select_protocol_prefers_default_when_supported(): + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + result = AuthProtocolRegistry.select_protocol( + ["api_key", "oauth2"], + default_protocol="oauth2", + ) + assert result == "oauth2" + + +def test_select_protocol_ignores_default_when_not_supported(): + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + result = AuthProtocolRegistry.select_protocol( + ["api_key"], + default_protocol="oauth2", + ) + assert result == "api_key" + + +def test_select_protocol_uses_preferences(): + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + result = AuthProtocolRegistry.select_protocol( + ["oauth2", "api_key"], + preferences={"oauth2": 10, "api_key": 1}, + ) + assert result == "api_key" + + +def test_select_protocol_preferences_unknown_protocol_gets_high_priority(): + AuthProtocolRegistry.register("oauth2", _MockOAuth2Protocol) + AuthProtocolRegistry.register("api_key", _MockApiKeyProtocol) + result = AuthProtocolRegistry.select_protocol( + ["oauth2", "api_key"], + preferences={"api_key": 999}, + ) + assert result in ("oauth2", "api_key") From dae53d70e433948decfc63e4661b01a65253618f Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 30 Jan 2026 00:09:44 +0800 Subject: [PATCH 18/64] test(auth): add run_authentication unit tests with mock HTTP --- tests/client/test_auth.py | 84 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 7ad24f2df..a2d256898 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -10,7 +10,7 @@ from inline_snapshot import Is, snapshot from pydantic import AnyHttpUrl, AnyUrl -from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth import OAuthClientProvider, OAuthFlowError, PKCEParameters from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -2133,3 +2133,85 @@ async def callback_handler() -> tuple[str, str | None]: await auth_flow.asend(final_response) except StopAsyncIteration: pass + + +class TestRunAuthentication: + """Unit tests for OAuthClientProvider.run_authentication (mock HTTP).""" + + @pytest.mark.anyio + async def test_run_authentication_with_prefilled_context_sets_tokens( + self, + oauth_provider: OAuthClientProvider, + mock_storage: MockTokenStorage, + ): + """run_authentication with pre-filled PRM/ASM/client_info only does token exchange; mock HTTP returns token.""" + oauth_provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + oauth_provider.context.auth_server_url = "https://auth.example.com" + oauth_provider.context.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"), + ) + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="client_secret_post", + ) + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + token_response = httpx.Response( + 200, + content=b'{"access_token": "at", "token_type": "Bearer", "expires_in": 3600, "scope": "read"}', + ) + mock_send = mock.AsyncMock(return_value=token_response) + client = mock.MagicMock(spec=httpx.AsyncClient) + client.send = mock_send + + await oauth_provider.run_authentication(client) + + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.access_token == "at" + assert mock_send.await_count == 1 + # Storage was updated by _handle_token_response + stored = await mock_storage.get_tokens() + assert stored is not None and stored.access_token == "at" + + @pytest.mark.anyio + async def test_run_authentication_raises_when_prm_discovery_fails( + self, + oauth_provider: OAuthClientProvider, + ): + """run_authentication raises OAuthFlowError when PRM discovery returns no valid metadata.""" + oauth_provider.context.protected_resource_metadata = None + oauth_provider.context.auth_server_url = None + not_found = httpx.Response(404, content=b"") + client = mock.MagicMock(spec=httpx.AsyncClient) + client.send = mock.AsyncMock(return_value=not_found) + + with pytest.raises(OAuthFlowError, match="Could not discover authorization server"): + await oauth_provider.run_authentication(client) + + @pytest.mark.anyio + async def test_run_authentication_raises_when_asm_discovery_fails( + self, + oauth_provider: OAuthClientProvider, + ): + """run_authentication raises OAuthFlowError when ASM discovery returns no valid metadata.""" + oauth_provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + oauth_provider.context.auth_server_url = "https://auth.example.com" + oauth_provider.context.oauth_metadata = None + not_found = httpx.Response(404, content=b"") + client = mock.MagicMock(spec=httpx.AsyncClient) + client.send = mock.AsyncMock(return_value=not_found) + + with pytest.raises(OAuthFlowError, match="Could not discover OAuth metadata"): + await oauth_provider.run_authentication(client) From f84fe64ea3ea3af3e787ec69eaba08cb98bea45b Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 31 Jan 2026 15:17:39 +0800 Subject: [PATCH 19/64] feat(auth): implement DPoP client (RFC 9449) with key pair, proof generator and storage - Add DPoPKeyPair for ES256/RS256 key generation with configurable RSA key size - Add DPoPProofGeneratorImpl for generating DPoP proof JWTs per RFC 9449 - Add InMemoryDPoPStorage for key pair persistence - Add compute_jwk_thumbprint for RFC 7638 JWK Thumbprint calculation - Include comprehensive unit tests (12 test cases) --- src/mcp/client/auth/dpop.py | 204 +++++++++++++++++++++++++++++++++ tests/client/auth/test_dpop.py | 121 +++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 src/mcp/client/auth/dpop.py create mode 100644 tests/client/auth/test_dpop.py diff --git a/src/mcp/client/auth/dpop.py b/src/mcp/client/auth/dpop.py new file mode 100644 index 000000000..17d2c11ac --- /dev/null +++ b/src/mcp/client/auth/dpop.py @@ -0,0 +1,204 @@ +""" +DPoP (Demonstrating Proof-of-Possession) client implementation. + +RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP). +Provides DPoPKeyPair, DPoPProofGenerator, DPoPStorage for generating DPoP proof JWTs. +""" + +import base64 +import hashlib +import secrets +import time +from typing import Any, Literal + +import jwt +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from mcp.client.auth.protocol import DPoPProofGenerator, DPoPStorage + +_BITS_PER_BYTE = 8 +# NIST SP 800-57 recommended minimum for RSA keys (valid through 2030+) +_RSA_KEY_SIZE_DEFAULT = 2048 +# RFC 8017 / cryptography library recommended value +_RSA_PUBLIC_EXPONENT = 65537 + + +def _int_to_base64url(num: int) -> str: + """Encode integer to base64url without padding.""" + size = (num.bit_length() + _BITS_PER_BYTE - 1) // _BITS_PER_BYTE + data = num.to_bytes(size, "big") + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +class DPoPKeyPair: + """DPoP key pair holding private key and public JWK.""" + + def __init__( + self, + private_key: EllipticCurvePrivateKey | RSAPrivateKey, + algorithm: Literal["ES256", "RS256"] = "ES256", + ) -> None: + self._private_key: EllipticCurvePrivateKey | RSAPrivateKey = private_key + self._algorithm = algorithm + self._public_jwk = _key_to_jwk(private_key) + + @property + def algorithm(self) -> str: + return self._algorithm + + @property + def public_key_jwk(self) -> dict[str, Any]: + return self._public_jwk.copy() + + @classmethod + def generate( + cls, + algorithm: Literal["ES256", "RS256"] = "ES256", + *, + rsa_key_size: int = _RSA_KEY_SIZE_DEFAULT, + ) -> "DPoPKeyPair": + """Generate a new DPoP key pair. + + Args: + algorithm: Signing algorithm, "ES256" (default) or "RS256". + rsa_key_size: RSA key size in bits (default 2048, minimum 2048). + Only used when algorithm is "RS256". + + Raises: + ValueError: If algorithm is unsupported or rsa_key_size < 2048. + """ + from cryptography.hazmat.primitives.asymmetric.ec import ( + SECP256R1, + ) + from cryptography.hazmat.primitives.asymmetric.ec import ( + generate_private_key as ec_generate, + ) + from cryptography.hazmat.primitives.asymmetric.rsa import ( + generate_private_key as rsa_generate, + ) + + if algorithm == "ES256": + key: EllipticCurvePrivateKey | RSAPrivateKey = ec_generate(SECP256R1()) + elif algorithm == "RS256": + if rsa_key_size < _RSA_KEY_SIZE_DEFAULT: + raise ValueError( + f"RSA key size must be at least {_RSA_KEY_SIZE_DEFAULT} bits, got {rsa_key_size}" + ) + key = rsa_generate(public_exponent=_RSA_PUBLIC_EXPONENT, key_size=rsa_key_size) + else: + raise ValueError(f"Unsupported algorithm: {algorithm}") + return cls(key, algorithm) + + def sign_dpop_jwt(self, payload: dict[str, Any], headers: dict[str, Any]) -> str: + """Sign a DPoP JWT with the private key.""" + return jwt.encode( + payload, + self._private_key, + algorithm=self._algorithm, + headers=headers, + ) + + +def _key_to_jwk(key: EllipticCurvePrivateKey | RSAPrivateKey) -> dict[str, Any]: + """Convert a private key to public JWK (no private components).""" + if isinstance(key, EllipticCurvePrivateKey): + pub = key.public_key() + nums = pub.public_numbers() + return { + "kty": "EC", + "crv": "P-256", + "x": _int_to_base64url(nums.x), + "y": _int_to_base64url(nums.y), + } + # key is RSAPrivateKey (union type) + pub = key.public_key() + nums = pub.public_numbers() + return { + "kty": "RSA", + "n": _int_to_base64url(nums.n), + "e": _int_to_base64url(nums.e), + } + + +class DPoPProofGeneratorImpl(DPoPProofGenerator): + """DPoP proof generator implementing the DPoPProofGenerator protocol.""" + + def __init__(self, key_pair: DPoPKeyPair) -> None: + self._key_pair = key_pair + + def generate_proof( + self, + method: str, + uri: str, + credential: str | None = None, + nonce: str | None = None, + ) -> str: + """Generate a DPoP proof JWT per RFC 9449.""" + htu = _normalize_htu(uri) + payload: dict[str, Any] = { + "jti": secrets.token_urlsafe(32), + "htm": method.upper(), + "htu": htu, + "iat": int(time.time()), + } + if credential: + payload["ath"] = _ath_hash(credential) + if nonce: + payload["nonce"] = nonce + + headers: dict[str, Any] = { + "typ": "dpop+jwt", + "alg": self._key_pair.algorithm, + "jwk": self._key_pair.public_key_jwk, + } + + return self._key_pair.sign_dpop_jwt(payload, headers) + + def get_public_key_jwk(self) -> dict[str, Any]: + return self._key_pair.public_key_jwk + + +def _normalize_htu(uri: str) -> str: + """Strip query and fragment from URI per RFC 9449 htu claim.""" + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(uri) + return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) + + +def _ath_hash(access_token: str) -> str: + """Base64url-encoded SHA-256 hash of ASCII access token.""" + digest = hashlib.sha256(access_token.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def compute_jwk_thumbprint(jwk: dict[str, Any]) -> str: + """Compute JWK Thumbprint (RFC 7638) for cnf.jkt binding.""" + import json + + kty = jwk.get("kty") + if kty == "EC": + canonical = {"crv": jwk["crv"], "kty": "EC", "x": jwk["x"], "y": jwk["y"]} + elif kty == "RSA": + canonical = {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]} + else: + raise ValueError(f"Unsupported key type: {kty}") + data = json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode() + return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode().rstrip("=") + + +class InMemoryDPoPStorage(DPoPStorage): + """In-memory DPoP key pair storage. + + Note: Not thread-safe. Suitable for single-threaded or async environments. + """ + + def __init__(self) -> None: + self._store: dict[str, DPoPKeyPair] = {} + + async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: + return self._store.get(protocol_id) + + async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: + self._store[protocol_id] = key_pair diff --git a/tests/client/auth/test_dpop.py b/tests/client/auth/test_dpop.py new file mode 100644 index 000000000..043053e12 --- /dev/null +++ b/tests/client/auth/test_dpop.py @@ -0,0 +1,121 @@ +"""Unit tests for DPoP client (DPoPKeyPair, DPoPProofGenerator, DPoPStorage).""" + +import base64 +import hashlib + +import jwt +import pytest + +from mcp.client.auth.dpop import ( + DPoPKeyPair, + DPoPProofGeneratorImpl, + InMemoryDPoPStorage, + compute_jwk_thumbprint, +) + + +def test_dpop_key_pair_generate_es256() -> None: + pair = DPoPKeyPair.generate("ES256") + assert pair.algorithm == "ES256" + jwk = pair.public_key_jwk + assert jwk["kty"] == "EC" + assert jwk["crv"] == "P-256" + assert "x" in jwk and "y" in jwk + + +def test_dpop_key_pair_generate_rs256() -> None: + pair = DPoPKeyPair.generate("RS256") + assert pair.algorithm == "RS256" + jwk = pair.public_key_jwk + assert jwk["kty"] == "RSA" + assert "n" in jwk and "e" in jwk + + +def test_dpop_proof_generator_produces_valid_jwt() -> None: + pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(pair) + proof = gen.generate_proof("POST", "https://example.com/token") + decoded = jwt.decode(proof, options={"verify_signature": False}) + assert decoded["htm"] == "POST" + assert decoded["htu"] == "https://example.com/token" + assert "jti" in decoded and "iat" in decoded + + +def test_dpop_proof_includes_ath_when_credential_provided() -> None: + pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(pair) + proof = gen.generate_proof("GET", "https://rs.example/res", credential="my-token") + decoded = jwt.decode(proof, options={"verify_signature": False}) + expected_ath = base64.urlsafe_b64encode( + hashlib.sha256(b"my-token").digest() + ).decode().rstrip("=") + assert decoded["ath"] == expected_ath + + +def test_dpop_proof_includes_nonce_when_provided() -> None: + pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(pair) + proof = gen.generate_proof("POST", "https://as.example/token", nonce="server-nonce") + decoded = jwt.decode(proof, options={"verify_signature": False}) + assert decoded["nonce"] == "server-nonce" + + +def test_dpop_proof_htu_strips_query_and_fragment() -> None: + pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(pair) + proof = gen.generate_proof("GET", "https://example.com/path?q=1#frag") + decoded = jwt.decode(proof, options={"verify_signature": False}) + assert decoded["htu"] == "https://example.com/path" + + +def test_dpop_proof_signature_verifiable() -> None: + pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(pair) + proof = gen.generate_proof("POST", "https://example.com/token") + header = jwt.get_unverified_header(proof) + assert header["typ"] == "dpop+jwt" + assert header["alg"] == "ES256" + assert "jwk" in header + + +@pytest.mark.anyio +async def test_in_memory_dpop_storage() -> None: + storage = InMemoryDPoPStorage() + pair = DPoPKeyPair.generate("ES256") + assert await storage.get_key_pair("oauth2") is None + await storage.set_key_pair("oauth2", pair) + retrieved = await storage.get_key_pair("oauth2") + assert retrieved is not None + assert retrieved.public_key_jwk == pair.public_key_jwk + + +def test_compute_jwk_thumbprint_ec() -> None: + pair = DPoPKeyPair.generate("ES256") + jwk = pair.public_key_jwk + thumbprint = compute_jwk_thumbprint(jwk) + # Thumbprint should be base64url-encoded SHA-256 (43 chars without padding) + assert len(thumbprint) == 43 + assert "=" not in thumbprint + + +def test_compute_jwk_thumbprint_rsa() -> None: + pair = DPoPKeyPair.generate("RS256") + jwk = pair.public_key_jwk + thumbprint = compute_jwk_thumbprint(jwk) + assert len(thumbprint) == 43 + assert "=" not in thumbprint + + +def test_dpop_key_pair_generate_rs256_custom_key_size() -> None: + pair = DPoPKeyPair.generate("RS256", rsa_key_size=4096) + assert pair.algorithm == "RS256" + jwk = pair.public_key_jwk + assert jwk["kty"] == "RSA" + # 4096-bit key has larger modulus than 2048-bit + # base64url of 4096-bit n is ~683 chars vs ~342 for 2048-bit + assert len(jwk["n"]) > 400 + + +def test_dpop_key_pair_generate_rs256_rejects_small_key_size() -> None: + with pytest.raises(ValueError, match="RSA key size must be at least 2048"): + DPoPKeyPair.generate("RS256", rsa_key_size=1024) From e83e9750585947daaa2c2b08cf0e6f423cff78a8 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 31 Jan 2026 16:41:00 +0800 Subject: [PATCH 20/64] feat(auth): implement DPoP server-side verification (RFC 9449) Add DPoPProofVerifier for validating DPoP proof JWTs per RFC 9449 Section 4.3, including replay protection, claim validation, and JWK thumbprint verification. --- src/mcp/server/auth/dpop.py | 222 ++++++++++++++++++++++++++ tests/server/auth/test_dpop_server.py | 148 +++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 src/mcp/server/auth/dpop.py create mode 100644 tests/server/auth/test_dpop_server.py diff --git a/src/mcp/server/auth/dpop.py b/src/mcp/server/auth/dpop.py new file mode 100644 index 000000000..19cc65183 --- /dev/null +++ b/src/mcp/server/auth/dpop.py @@ -0,0 +1,222 @@ +""" +DPoP (Demonstrating Proof-of-Possession) server-side verification. + +RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP). +Provides DPoPProofVerifier for validating DPoP proof JWTs and jti replay protection. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import time +from dataclasses import dataclass +from typing import Any, Protocol, cast +from urllib.parse import urlparse, urlunparse + +import jwt +from jwt import PyJWK + +_SUPPORTED_ALGS = {"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512"} +_DEFAULT_IAT_WINDOW = 300 # seconds +DPOP_HEADER = "DPoP" + + +@dataclass +class DPoPProofInfo: + """Validated DPoP proof information.""" + + jti: str + htm: str + htu: str + iat: int + ath: str | None + nonce: str | None + jwk: dict[str, Any] + jwk_thumbprint: str + + +class DPoPVerificationError(Exception): + """DPoP verification failure with error code.""" + + def __init__(self, error_code: str, message: str) -> None: + self.error_code = error_code + self.message = message + super().__init__(message) + + +class JTIReplayStore(Protocol): + """Protocol for jti replay protection storage.""" + + async def check_and_store(self, jti: str, exp_time: float) -> bool: + """Check if jti is new (True) or replay (False), and store if new.""" + ... + + +class DPoPNonceStore(Protocol): + """Protocol for server-managed DPoP nonce (optional feature).""" + + async def generate_nonce(self) -> str: ... + async def validate_nonce(self, nonce: str) -> bool: ... + + +class InMemoryJTIReplayStore: + """In-memory jti replay store. Not for distributed systems.""" + + def __init__(self, max_size: int = 10000) -> None: + self._store: dict[str, float] = {} + self._max_size = max_size + + async def check_and_store(self, jti: str, exp_time: float) -> bool: + now = time.time() + if len(self._store) > self._max_size * 0.9: + self._store = {k: v for k, v in self._store.items() if v > now} + if jti in self._store: + return False + self._store[jti] = exp_time + return True + + +class DPoPProofVerifier: + """DPoP proof verifier per RFC 9449 Section 4.3.""" + + def __init__( + self, + *, + jti_store: JTIReplayStore | None = None, + iat_window: int = _DEFAULT_IAT_WINDOW, + ) -> None: + self._jti_store = jti_store + self._iat_window = iat_window + + async def verify( + self, + dpop_proof: str, + http_method: str, + http_uri: str, + *, + access_token: str | None = None, + expected_jkt: str | None = None, + ) -> DPoPProofInfo: + """Verify DPoP proof per RFC 9449. Raises DPoPVerificationError on failure.""" + try: + header = jwt.get_unverified_header(dpop_proof) + except jwt.exceptions.DecodeError as e: + raise DPoPVerificationError("invalid_dpop_proof", f"Malformed JWT: {e}") from e + + if header.get("typ") != "dpop+jwt": + raise DPoPVerificationError("invalid_dpop_proof", "Invalid typ") + alg = header.get("alg") + if not alg or alg == "none" or alg not in _SUPPORTED_ALGS: + raise DPoPVerificationError("invalid_dpop_proof", f"Invalid algorithm: {alg}") + + jwk_raw = header.get("jwk") + if not jwk_raw or not isinstance(jwk_raw, dict): + raise DPoPVerificationError("invalid_dpop_proof", "Missing or invalid jwk") + jwk_dict = cast(dict[str, Any], jwk_raw) + if any(k in jwk_dict for k in ("d", "p", "q", "dp", "dq", "qi", "k")): + raise DPoPVerificationError("invalid_dpop_proof", "jwk contains private key") + + try: + payload = jwt.decode( + dpop_proof, + key=PyJWK.from_dict(jwk_dict), + algorithms=[alg], + options={ + "verify_signature": True, + "verify_exp": False, + "verify_nbf": False, + "verify_iat": False, + "verify_aud": False, + "verify_iss": False, + "require": [], + }, + ) + except jwt.exceptions.InvalidSignatureError as e: + raise DPoPVerificationError("invalid_dpop_proof", "Signature failed") from e + except jwt.exceptions.DecodeError as e: + raise DPoPVerificationError("invalid_dpop_proof", f"Decode failed: {e}") from e + + for claim in ("jti", "htm", "htu", "iat"): + if claim not in payload: + raise DPoPVerificationError("invalid_dpop_proof", f"Missing {claim}") + + jti, htm, htu, iat = payload["jti"], payload["htm"], payload["htu"], payload["iat"] + + # Validate claim types to prevent AttributeError on malformed payloads + if not isinstance(jti, str) or not jti: + raise DPoPVerificationError("invalid_dpop_proof", "Invalid jti: must be non-empty string") + if not isinstance(htm, str) or not htm: + raise DPoPVerificationError("invalid_dpop_proof", "Invalid htm: must be non-empty string") + if not isinstance(htu, str) or not htu: + raise DPoPVerificationError("invalid_dpop_proof", "Invalid htu: must be non-empty string") + + if htm.upper() != http_method.upper(): + raise DPoPVerificationError("invalid_dpop_proof", "htm mismatch") + if htu != _normalize_uri(http_uri): + raise DPoPVerificationError("invalid_dpop_proof", "htu mismatch") + + now = time.time() + if not isinstance(iat, int | float) or abs(now - iat) > self._iat_window: + raise DPoPVerificationError("invalid_dpop_proof", "Invalid iat") + if self._jti_store and not await self._jti_store.check_and_store(jti, now + self._iat_window): + raise DPoPVerificationError("invalid_dpop_proof", "Replay detected") + + ath = payload.get("ath") + if access_token and ath != _compute_ath(access_token): + raise DPoPVerificationError("invalid_dpop_proof", "ath mismatch") + + thumbprint = _compute_thumbprint(jwk_dict) + if expected_jkt and thumbprint != expected_jkt: + raise DPoPVerificationError("invalid_dpop_proof", "jkt mismatch") + + return DPoPProofInfo( + jti=jti, + htm=htm, + htu=htu, + iat=int(iat), + ath=ath, + nonce=payload.get("nonce"), + jwk=jwk_dict, + jwk_thumbprint=thumbprint, + ) + + +def _normalize_uri(uri: str) -> str: + p = urlparse(uri) + return urlunparse((p.scheme, p.netloc, p.path, "", "", "")) + + +def _compute_ath(token: str) -> str: + return base64.urlsafe_b64encode(hashlib.sha256(token.encode("ascii")).digest()).decode().rstrip("=") + + +def _compute_thumbprint(jwk: dict[str, Any]) -> str: + kty = jwk.get("kty") + if kty == "EC": + canonical = { + "crv": jwk["crv"], + "kty": "EC", + "x": jwk["x"], + "y": jwk["y"], + } + elif kty == "RSA": + canonical = { + "e": jwk["e"], + "kty": "RSA", + "n": jwk["n"], + } + else: + raise DPoPVerificationError("invalid_dpop_proof", f"Unsupported kty: {kty}") + return base64.urlsafe_b64encode( + hashlib.sha256(json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode()).digest() + ).decode().rstrip("=") + + +def extract_dpop_proof(headers: dict[str, str]) -> str | None: + """Extract DPoP proof from headers (case-insensitive).""" + for k, v in headers.items(): + if k.lower() == "dpop": + return v + return None diff --git a/tests/server/auth/test_dpop_server.py b/tests/server/auth/test_dpop_server.py new file mode 100644 index 000000000..da6861a2b --- /dev/null +++ b/tests/server/auth/test_dpop_server.py @@ -0,0 +1,148 @@ +"""Unit tests for DPoP server-side verification.""" + +import time + +import pytest + +from mcp.client.auth.dpop import DPoPKeyPair, DPoPProofGeneratorImpl, compute_jwk_thumbprint +from mcp.server.auth.dpop import ( + DPoPProofInfo, + DPoPProofVerifier, + DPoPVerificationError, + InMemoryJTIReplayStore, + extract_dpop_proof, +) + + +@pytest.fixture +def verifier() -> DPoPProofVerifier: + return DPoPProofVerifier() + + +@pytest.fixture +def key_pair() -> DPoPKeyPair: + return DPoPKeyPair.generate("ES256") + + +@pytest.fixture +def gen(key_pair: DPoPKeyPair) -> DPoPProofGeneratorImpl: + return DPoPProofGeneratorImpl(key_pair) + + +@pytest.mark.anyio +async def test_verify_valid_proof(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("POST", "https://server.example.com/token") + result = await verifier.verify(proof, "POST", "https://server.example.com/token") + assert isinstance(result, DPoPProofInfo) + assert result.htm == "POST" and result.htu == "https://server.example.com/token" + + +@pytest.mark.anyio +async def test_verify_with_access_token(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("GET", "https://api.example.com/res", credential="test-token") + result = await verifier.verify(proof, "GET", "https://api.example.com/res", access_token="test-token") + assert result.ath is not None + + +@pytest.mark.anyio +async def test_verify_with_expected_jkt( + verifier: DPoPProofVerifier, key_pair: DPoPKeyPair, gen: DPoPProofGeneratorImpl +) -> None: + proof = gen.generate_proof("POST", "https://server.example.com/token") + jkt = compute_jwk_thumbprint(key_pair.public_key_jwk) + result = await verifier.verify(proof, "POST", "https://server.example.com/token", expected_jkt=jkt) + assert result.jwk_thumbprint == jkt + + +@pytest.mark.anyio +async def test_rejects_htm_mismatch(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("POST", "https://server.example.com/token") + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(proof, "GET", "https://server.example.com/token") + assert exc.value.error_code == "invalid_dpop_proof" + + +@pytest.mark.anyio +async def test_rejects_htu_mismatch(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("POST", "https://server.example.com/token") + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(proof, "POST", "https://other.example.com/token") + assert exc.value.error_code == "invalid_dpop_proof" + + +@pytest.mark.anyio +async def test_accepts_uri_with_query(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("GET", "https://api.example.com/resource") + result = await verifier.verify(proof, "GET", "https://api.example.com/resource?foo=bar#frag") + assert result.htu == "https://api.example.com/resource" + + +@pytest.mark.anyio +async def test_rejects_ath_mismatch(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("GET", "https://api.example.com/res", credential="token-a") + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(proof, "GET", "https://api.example.com/res", access_token="token-b") + assert "ath mismatch" in exc.value.message + + +@pytest.mark.anyio +async def test_rejects_jkt_mismatch(verifier: DPoPProofVerifier, gen: DPoPProofGeneratorImpl) -> None: + proof = gen.generate_proof("POST", "https://server.example.com/token") + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(proof, "POST", "https://server.example.com/token", expected_jkt="wrong") + assert "jkt mismatch" in exc.value.message + + +@pytest.mark.anyio +async def test_verify_rs256() -> None: + verifier = DPoPProofVerifier() + kp = DPoPKeyPair.generate("RS256") + proof = DPoPProofGeneratorImpl(kp).generate_proof("POST", "https://server.example.com/token") + result = await verifier.verify(proof, "POST", "https://server.example.com/token") + assert result.jwk["kty"] == "RSA" + + +def test_extract_dpop_proof_case_insensitive() -> None: + assert extract_dpop_proof({"DPoP": "p1"}) == "p1" + assert extract_dpop_proof({"dpop": "p2"}) == "p2" + assert extract_dpop_proof({"Authorization": "Bearer x"}) is None + + +@pytest.mark.anyio +async def test_jti_store_detects_replay() -> None: + store = InMemoryJTIReplayStore() + exp = time.time() + 300 + assert await store.check_and_store("jti-1", exp) is True + assert await store.check_and_store("jti-1", exp) is False + assert await store.check_and_store("jti-2", exp) is True + + +@pytest.mark.anyio +async def test_verifier_with_jti_store_rejects_replay(gen: DPoPProofGeneratorImpl) -> None: + store = InMemoryJTIReplayStore() + verifier = DPoPProofVerifier(jti_store=store) + proof = gen.generate_proof("POST", "https://server.example.com/token") + await verifier.verify(proof, "POST", "https://server.example.com/token") + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(proof, "POST", "https://server.example.com/token") + assert "Replay" in exc.value.message + + +@pytest.mark.anyio +async def test_rejects_invalid_claim_types(verifier: DPoPProofVerifier, key_pair: DPoPKeyPair) -> None: + """Verify that non-string claim types are rejected with DPoPVerificationError.""" + import jwt as pyjwt + + # Create a proof with invalid htm type (integer instead of string) + header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": key_pair.public_key_jwk} + payload = { + "jti": "test-jti", + "htm": 123, # Invalid: should be string + "htu": "https://example.com/token", + "iat": int(time.time()), + } + invalid_proof = pyjwt.encode(payload, key_pair._private_key, algorithm="ES256", headers=header) + + with pytest.raises(DPoPVerificationError) as exc: + await verifier.verify(invalid_proof, "POST", "https://example.com/token") + assert "Invalid htm" in exc.value.message From 44901e19989087083a90a1e34ed02a8209cb7169 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 31 Jan 2026 17:59:31 +0800 Subject: [PATCH 21/64] feat(auth): integrate DPoP into OAuth2Protocol and credential verifiers Add DPoP support to OAuth2Protocol implementing DPoPEnabledProtocol, inject DPoP proofs via MultiProtocolAuthProvider, and verify DPoP-bound tokens in OAuthTokenVerifier. --- src/mcp/client/auth/dpop.py | 14 +- src/mcp/client/auth/multi_protocol.py | 31 +++- src/mcp/client/auth/protocol.py | 5 +- src/mcp/client/auth/protocols/oauth2.py | 52 +++++- src/mcp/server/auth/verifiers.py | 60 +++++- tests/client/auth/test_dpop_integration.py | 205 +++++++++++++++++++++ tests/client/auth/test_oauth2_protocol.py | 2 +- tests/server/auth/test_verifiers_dpop.py | 194 +++++++++++++++++++ 8 files changed, 536 insertions(+), 27 deletions(-) create mode 100644 tests/client/auth/test_dpop_integration.py create mode 100644 tests/server/auth/test_verifiers_dpop.py diff --git a/src/mcp/client/auth/dpop.py b/src/mcp/client/auth/dpop.py index 17d2c11ac..807e35aa5 100644 --- a/src/mcp/client/auth/dpop.py +++ b/src/mcp/client/auth/dpop.py @@ -17,9 +17,11 @@ from mcp.client.auth.protocol import DPoPProofGenerator, DPoPStorage +DPoPAlgorithm = Literal["ES256", "RS256"] + _BITS_PER_BYTE = 8 # NIST SP 800-57 recommended minimum for RSA keys (valid through 2030+) -_RSA_KEY_SIZE_DEFAULT = 2048 +RSA_KEY_SIZE_DEFAULT = 2048 # RFC 8017 / cryptography library recommended value _RSA_PUBLIC_EXPONENT = 65537 @@ -37,7 +39,7 @@ class DPoPKeyPair: def __init__( self, private_key: EllipticCurvePrivateKey | RSAPrivateKey, - algorithm: Literal["ES256", "RS256"] = "ES256", + algorithm: DPoPAlgorithm = "ES256", ) -> None: self._private_key: EllipticCurvePrivateKey | RSAPrivateKey = private_key self._algorithm = algorithm @@ -54,9 +56,9 @@ def public_key_jwk(self) -> dict[str, Any]: @classmethod def generate( cls, - algorithm: Literal["ES256", "RS256"] = "ES256", + algorithm: DPoPAlgorithm = "ES256", *, - rsa_key_size: int = _RSA_KEY_SIZE_DEFAULT, + rsa_key_size: int = RSA_KEY_SIZE_DEFAULT, ) -> "DPoPKeyPair": """Generate a new DPoP key pair. @@ -81,9 +83,9 @@ def generate( if algorithm == "ES256": key: EllipticCurvePrivateKey | RSAPrivateKey = ec_generate(SECP256R1()) elif algorithm == "RS256": - if rsa_key_size < _RSA_KEY_SIZE_DEFAULT: + if rsa_key_size < RSA_KEY_SIZE_DEFAULT: raise ValueError( - f"RSA key size must be at least {_RSA_KEY_SIZE_DEFAULT} bits, got {rsa_key_size}" + f"RSA key size must be at least {RSA_KEY_SIZE_DEFAULT} bits, got {rsa_key_size}" ) key = rsa_generate(public_exponent=_RSA_PUBLIC_EXPONENT, key_size=rsa_key_size) else: diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index b630f523e..d0b90450c 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -12,7 +12,7 @@ import anyio import httpx -from mcp.client.auth.protocol import AuthContext, AuthProtocol +from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol from mcp.client.auth.registry import AuthProtocolRegistry from mcp.client.auth.utils import ( build_protected_resource_metadata_discovery_urls, @@ -149,12 +149,37 @@ def _is_credentials_valid(self, credentials: AuthCredentials | None) -> bool: return False return protocol.validate_credentials(credentials) + async def _ensure_dpop_initialized(self, credentials: AuthCredentials) -> None: + """Ensure DPoP is initialized for the protocol if enabled.""" + if not self.dpop_enabled: + return + protocol = self._get_protocol(credentials.protocol_id) + if protocol is not None and isinstance(protocol, DPoPEnabledProtocol): + if protocol.supports_dpop(): + await protocol.initialize_dpop() + def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: - """为请求添加协议指定的认证信息(仅协议 prepare_request,不含 DPoP)。""" + """为请求添加协议指定的认证信息,包括 DPoP proof(如启用)。""" protocol = self._get_protocol(credentials.protocol_id) if protocol is not None: protocol.prepare_request(request, credentials) + # Generate and attach DPoP proof if enabled and protocol supports it + if self.dpop_enabled and isinstance(protocol, DPoPEnabledProtocol): + if protocol.supports_dpop(): + generator = protocol.get_dpop_proof_generator() + if generator is not None: + # Get access token for ath claim binding + access_token: str | None = None + if isinstance(credentials, OAuthCredentials): + access_token = credentials.access_token + proof = generator.generate_proof( + str(request.method), + str(request.url), + credential=access_token, + ) + request.headers["DPoP"] = proof + async def _fetch_prm( self, resource_metadata_url: str | None, server_url: str ) -> ProtectedResourceMetadata | None: @@ -271,6 +296,7 @@ async def async_auth_flow( # 无有效凭证时直接发送请求,依赖 401 响应后再做发现与认证(见下方 401 处理) pass else: + await self._ensure_dpop_initialized(credentials) self._prepare_request(request, credentials) response = yield request @@ -280,6 +306,7 @@ async def async_auth_flow( await self._handle_401_response(response, request) credentials = await self._get_credentials() if credentials and self._is_credentials_valid(credentials): + await self._ensure_dpop_initialized(credentials) self._prepare_request(request, credentials) response = yield request elif response.status_code == 403: diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py index d2a933d47..64f089cd4 100644 --- a/src/mcp/client/auth/protocol.py +++ b/src/mcp/client/auth/protocol.py @@ -5,7 +5,7 @@ """ from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any, Protocol, runtime_checkable import httpx @@ -128,7 +128,8 @@ async def register_client(self, context: AuthContext) -> ClientRegistrationResul ... -class DPoPEnabledProtocol(AuthProtocol): +@runtime_checkable +class DPoPEnabledProtocol(AuthProtocol, Protocol): """支持DPoP的协议扩展接口(阶段4实现)""" def supports_dpop(self) -> bool: diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py index 0b5cd07a4..e05453dbd 100644 --- a/src/mcp/client/auth/protocols/oauth2.py +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -10,18 +10,24 @@ import logging import time from collections.abc import Awaitable, Callable +from typing import Any import httpx +from pydantic import AnyHttpUrl +from mcp.client.auth.dpop import ( + RSA_KEY_SIZE_DEFAULT, + DPoPAlgorithm, + DPoPKeyPair, + DPoPProofGeneratorImpl, +) from mcp.client.auth.oauth2 import OAuthClientProvider -from mcp.client.auth.protocol import AuthContext +from mcp.client.auth.protocol import AuthContext, DPoPProofGenerator from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, create_oauth_metadata_request, handle_auth_metadata_response, ) -from pydantic import AnyHttpUrl - from mcp.shared.auth import ( AuthCredentials, AuthProtocolMetadata, @@ -84,12 +90,12 @@ class OAuth2Protocol: """ OAuth 2.0 协议薄适配层。 - 实现 AuthProtocol,authenticate 委托 OAuthClientProvider.run_authentication, - 不重复实现 OAuth 流程。 + 实现 AuthProtocol 和 DPoPEnabledProtocol,authenticate 委托 OAuthClientProvider.run_authentication, + 不重复实现 OAuth 流程。DPoP 支持通过 dpop_enabled 配置启用。 """ protocol_id: str = "oauth2" - protocol_version: str = "1.0" + protocol_version: str = "2.0" def __init__( self, @@ -98,12 +104,20 @@ def __init__( callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, client_metadata_url: str | None = None, + dpop_enabled: bool = False, + dpop_algorithm: DPoPAlgorithm = "ES256", + dpop_rsa_key_size: int = RSA_KEY_SIZE_DEFAULT, ): self._client_metadata = client_metadata self._redirect_handler = redirect_handler self._callback_handler = callback_handler self._timeout = timeout self._client_metadata_url = client_metadata_url + self._dpop_enabled = dpop_enabled + self._dpop_algorithm: DPoPAlgorithm = dpop_algorithm + self._dpop_rsa_key_size = dpop_rsa_key_size + self._dpop_key_pair: DPoPKeyPair | None = None + self._dpop_generator: DPoPProofGeneratorImpl | None = None async def authenticate(self, context: AuthContext) -> AuthCredentials: """从 AuthContext 组装 OAuth 上下文,委托 OAuthClientProvider.run_authentication,返回 OAuthCredentials。""" @@ -195,3 +209,29 @@ async def discover_metadata( except Exception as e: logger.debug("OAuth AS metadata discovery failed for %s: %s", url, e) return None + + # DPoPEnabledProtocol implementation + + def supports_dpop(self) -> bool: + """Check if DPoP is enabled for this protocol instance.""" + return self._dpop_enabled + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + """Get the DPoP proof generator if DPoP is initialized.""" + return self._dpop_generator + + async def initialize_dpop(self) -> None: + """Initialize DPoP by generating a key pair and creating the proof generator.""" + if not self._dpop_enabled: + return + if self._dpop_key_pair is None: + self._dpop_key_pair = DPoPKeyPair.generate( + self._dpop_algorithm, rsa_key_size=self._dpop_rsa_key_size + ) + self._dpop_generator = DPoPProofGeneratorImpl(self._dpop_key_pair) + + def get_dpop_public_key_jwk(self) -> dict[str, Any] | None: + """Get the DPoP public key JWK for token binding (cnf.jkt).""" + if self._dpop_generator is not None: + return self._dpop_generator.get_public_key_jwk() + return None diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py index e4d210282..bf05890d2 100644 --- a/src/mcp/server/auth/verifiers.py +++ b/src/mcp/server/auth/verifiers.py @@ -8,10 +8,12 @@ from starlette.requests import Request +from mcp.server.auth.dpop import DPoPProofVerifier, DPoPVerificationError, extract_dpop_proof from mcp.server.auth.provider import AccessToken, TokenVerifier BEARER_PREFIX = "Bearer " -APIKEY_HEADER = "x-api-key" # if found, use it; if not, use Authorization: Bearer +DPOP_PREFIX = "DPoP " +APIKEY_HEADER = "x-api-key" # if found, use it; if not, use Authorization: Bearer class CredentialVerifier(Protocol): @@ -37,9 +39,10 @@ async def verify( class OAuthTokenVerifier: """ - OAuth Bearer 凭证验证器。 + OAuth Bearer/DPoP 凭证验证器。 - 封装现有 TokenVerifier,仅做 Bearer 校验;DPoP 参数占位,阶段4 再实现。 + 支持 Bearer 和 DPoP 两种 token 类型。当提供 dpop_verifier 时,会验证 DPoP proof + 的签名、htm/htu/iat/ath 等声明。注:cnf.jkt 绑定检查暂未实现(需 AccessToken 扩展)。 """ def __init__(self, token_verifier: TokenVerifier) -> None: @@ -50,16 +53,53 @@ async def verify( request: Request, dpop_verifier: Any = None, ) -> AccessToken | None: - auth_header = next( - (request.headers.get(key) for key in request.headers if key.lower() == "authorization"), - None, - ) - if not auth_header or not auth_header.lower().startswith(BEARER_PREFIX.lower()): + auth_header = _get_header_ignore_case(request, "authorization") + if not auth_header: return None - token = auth_header[len(BEARER_PREFIX) :].strip() + + # Determine token type and extract token + token: str | None = None + is_dpop_bound = False + + if auth_header.lower().startswith(DPOP_PREFIX.lower()): + # DPoP-bound access token (Authorization: DPoP ) + token = auth_header[len(DPOP_PREFIX):].strip() + is_dpop_bound = True + elif auth_header.lower().startswith(BEARER_PREFIX.lower()): + token = auth_header[len(BEARER_PREFIX):].strip() + if not token: return None - return await self._token_verifier.verify_token(token) + + # Verify the token itself + access_token = await self._token_verifier.verify_token(token) + if access_token is None: + return None + + # DPoP verification if verifier provided and DPoP header present + if dpop_verifier is not None and isinstance(dpop_verifier, DPoPProofVerifier): + headers_dict = dict(request.headers) + dpop_proof = extract_dpop_proof(headers_dict) + + if is_dpop_bound and not dpop_proof: + # DPoP-bound token requires DPoP proof + return None + + if dpop_proof: + try: + http_uri = str(request.url) + http_method = request.method + + await dpop_verifier.verify( + dpop_proof, + http_method, + http_uri, + access_token=token, + ) + except DPoPVerificationError: + return None + + return access_token def _get_header_ignore_case(request: Request, name: str) -> str | None: diff --git a/tests/client/auth/test_dpop_integration.py b/tests/client/auth/test_dpop_integration.py new file mode 100644 index 000000000..5f7f85a25 --- /dev/null +++ b/tests/client/auth/test_dpop_integration.py @@ -0,0 +1,205 @@ +"""Unit tests for DPoP integration with OAuth2Protocol and MultiProtocolAuthProvider.""" + +import httpx +import pytest +from pydantic import AnyHttpUrl + +from mcp.client.auth.multi_protocol import MultiProtocolAuthProvider +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol +from mcp.shared.auth import AuthCredentials, OAuthClientMetadata, OAuthCredentials, OAuthToken + + +@pytest.fixture +def client_metadata() -> OAuthClientMetadata: + return OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")], + client_name="Test Client", + ) + + +def test_oauth2_protocol_dpop_disabled_by_default(client_metadata: OAuthClientMetadata) -> None: + """OAuth2Protocol should have DPoP disabled by default.""" + protocol = OAuth2Protocol(client_metadata=client_metadata) + assert protocol.supports_dpop() is False + assert protocol.get_dpop_proof_generator() is None + + +def test_oauth2_protocol_dpop_enabled(client_metadata: OAuthClientMetadata) -> None: + """OAuth2Protocol should report DPoP support when enabled.""" + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True) + assert protocol.supports_dpop() is True + # Generator is None until initialize_dpop is called + assert protocol.get_dpop_proof_generator() is None + + +@pytest.mark.anyio +async def test_oauth2_protocol_initialize_dpop(client_metadata: OAuthClientMetadata) -> None: + """initialize_dpop should create key pair and generator.""" + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True) + await protocol.initialize_dpop() + + generator = protocol.get_dpop_proof_generator() + assert generator is not None + + jwk = protocol.get_dpop_public_key_jwk() + assert jwk is not None + assert jwk.get("kty") == "EC" + + +@pytest.mark.anyio +async def test_oauth2_protocol_initialize_dpop_rs256(client_metadata: OAuthClientMetadata) -> None: + """initialize_dpop should support RS256 algorithm.""" + protocol = OAuth2Protocol( + client_metadata=client_metadata, dpop_enabled=True, dpop_algorithm="RS256" + ) + await protocol.initialize_dpop() + + jwk = protocol.get_dpop_public_key_jwk() + assert jwk is not None + assert jwk.get("kty") == "RSA" + + +@pytest.mark.anyio +async def test_oauth2_protocol_initialize_dpop_custom_rsa_key_size( + client_metadata: OAuthClientMetadata, +) -> None: + """initialize_dpop should support custom RSA key size.""" + protocol = OAuth2Protocol( + client_metadata=client_metadata, + dpop_enabled=True, + dpop_algorithm="RS256", + dpop_rsa_key_size=4096, + ) + await protocol.initialize_dpop() + + jwk = protocol.get_dpop_public_key_jwk() + assert jwk is not None + assert jwk.get("kty") == "RSA" + # 4096-bit RSA key has a longer 'n' (modulus) than 2048-bit + n_value = jwk.get("n", "") + # Base64url-encoded 4096-bit key's n should be ~683 chars (4096/8 * 4/3) + assert len(n_value) > 300 # 2048-bit would be ~342 chars + + +@pytest.mark.anyio +async def test_oauth2_protocol_initialize_dpop_noop_when_disabled( + client_metadata: OAuthClientMetadata, +) -> None: + """initialize_dpop should be a no-op when DPoP is disabled.""" + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=False) + await protocol.initialize_dpop() + assert protocol.get_dpop_proof_generator() is None + + +@pytest.mark.anyio +async def test_dpop_proof_generation(client_metadata: OAuthClientMetadata) -> None: + """DPoP proof generator should create valid proofs.""" + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True) + await protocol.initialize_dpop() + + generator = protocol.get_dpop_proof_generator() + assert generator is not None + + proof = generator.generate_proof("POST", "https://example.com/token") + assert proof is not None + assert len(proof) > 0 + + # Proof with access token binding + proof_with_ath = generator.generate_proof( + "GET", "https://api.example.com/resource", credential="access-token-123" + ) + assert proof_with_ath is not None + assert proof_with_ath != proof + + +class MockStorage: + """Mock storage for testing.""" + + def __init__(self, tokens: OAuthToken | OAuthCredentials | None = None) -> None: + self._tokens: AuthCredentials | OAuthToken | None = tokens + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + self._tokens = tokens + + +@pytest.mark.anyio +async def test_multi_protocol_provider_dpop_header_injection( + client_metadata: OAuthClientMetadata, +) -> None: + """MultiProtocolAuthProvider should inject DPoP header when dpop_enabled=True.""" + # Setup protocol with DPoP enabled + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True) + + # Setup storage with valid credentials + credentials = OAuthCredentials( + protocol_id="oauth2", + access_token="test-access-token", + token_type="Bearer", + expires_at=None, + ) + storage = MockStorage(credentials) + + # Create provider with DPoP enabled + provider = MultiProtocolAuthProvider( + server_url="https://example.com", + storage=storage, + protocols=[protocol], + dpop_enabled=True, + ) + + # Create a test request + request = httpx.Request("GET", "https://example.com/api/resource") + + # Run auth flow (first yield) + flow = provider.async_auth_flow(request) + prepared_request = await flow.__anext__() + + # Verify DPoP header was injected + assert "DPoP" in prepared_request.headers + assert prepared_request.headers["Authorization"] == "Bearer test-access-token" + + # Clean up generator + try: + await flow.athrow(GeneratorExit) + except (StopAsyncIteration, GeneratorExit): + pass + + +@pytest.mark.anyio +async def test_multi_protocol_provider_no_dpop_when_disabled( + client_metadata: OAuthClientMetadata, +) -> None: + """MultiProtocolAuthProvider should not inject DPoP header when dpop_enabled=False.""" + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=False) + + credentials = OAuthCredentials( + protocol_id="oauth2", + access_token="test-access-token", + token_type="Bearer", + expires_at=None, + ) + storage = MockStorage(credentials) + + provider = MultiProtocolAuthProvider( + server_url="https://example.com", + storage=storage, + protocols=[protocol], + dpop_enabled=False, + ) + + request = httpx.Request("GET", "https://example.com/api/resource") + + flow = provider.async_auth_flow(request) + prepared_request = await flow.__anext__() + + # DPoP header should NOT be present + assert "DPoP" not in prepared_request.headers + assert prepared_request.headers["Authorization"] == "Bearer test-access-token" + + try: + await flow.athrow(GeneratorExit) + except (StopAsyncIteration, GeneratorExit): + pass diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index 2c7dca96f..777f94d36 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -38,7 +38,7 @@ def oauth2_protocol(client_metadata: OAuthClientMetadata) -> OAuth2Protocol: def test_oauth2_protocol_id_and_version(oauth2_protocol: OAuth2Protocol) -> None: assert oauth2_protocol.protocol_id == "oauth2" - assert oauth2_protocol.protocol_version == "1.0" + assert oauth2_protocol.protocol_version == "2.0" def test_prepare_request_sets_bearer_header(oauth2_protocol: OAuth2Protocol) -> None: diff --git a/tests/server/auth/test_verifiers_dpop.py b/tests/server/auth/test_verifiers_dpop.py new file mode 100644 index 000000000..0401b7888 --- /dev/null +++ b/tests/server/auth/test_verifiers_dpop.py @@ -0,0 +1,194 @@ +"""Unit tests for DPoP integration with OAuthTokenVerifier.""" + +import pytest +from starlette.requests import Request + +from mcp.client.auth.dpop import DPoPKeyPair, DPoPProofGeneratorImpl +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import OAuthTokenVerifier + + +class MockTokenVerifier: + """Mock TokenVerifier for testing.""" + + def __init__(self, valid_tokens: dict[str, AccessToken]) -> None: + self._valid_tokens = valid_tokens + + async def verify_token(self, token: str) -> AccessToken | None: + return self._valid_tokens.get(token) + + +def _make_request( + method: str, + url: str, + headers: dict[str, str], +) -> Request: + """Create a Starlette Request for testing.""" + # Extract path from URL (e.g., "https://example.com/api/resource" -> "/api/resource") + if "://" in url: + path_part = url.split("://")[-1].split("/", 1) + path = "/" + path_part[1] if len(path_part) > 1 else "/" + else: + path = url + scope = { + "type": "http", + "method": method, + "path": path, + "query_string": b"", + "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()], + "server": ("example.com", 443), + "scheme": "https", + } + return Request(scope) + + +@pytest.fixture +def valid_token() -> AccessToken: + return AccessToken( + token="valid-access-token", + client_id="test-client", + scopes=["read", "write"], + ) + + +@pytest.fixture +def token_verifier(valid_token: AccessToken) -> MockTokenVerifier: + return MockTokenVerifier({"valid-access-token": valid_token}) + + +@pytest.fixture +def oauth_verifier(token_verifier: MockTokenVerifier) -> OAuthTokenVerifier: + return OAuthTokenVerifier(token_verifier) + + +@pytest.fixture +def dpop_verifier() -> DPoPProofVerifier: + return DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + +@pytest.fixture +def key_pair() -> DPoPKeyPair: + return DPoPKeyPair.generate("ES256") + + +@pytest.fixture +def dpop_generator(key_pair: DPoPKeyPair) -> DPoPProofGeneratorImpl: + return DPoPProofGeneratorImpl(key_pair) + + +@pytest.mark.anyio +async def test_bearer_token_without_dpop(oauth_verifier: OAuthTokenVerifier) -> None: + """Bearer token should work without DPoP verification.""" + request = _make_request( + "GET", + "https://example.com/api/resource", + {"Authorization": "Bearer valid-access-token"}, + ) + result = await oauth_verifier.verify(request) + assert result is not None + assert result.token == "valid-access-token" + + +@pytest.mark.anyio +async def test_bearer_token_with_dpop_verifier_no_proof( + oauth_verifier: OAuthTokenVerifier, + dpop_verifier: DPoPProofVerifier, +) -> None: + """Bearer token without DPoP proof should still work when dpop_verifier provided.""" + request = _make_request( + "GET", + "https://example.com/api/resource", + {"Authorization": "Bearer valid-access-token"}, + ) + result = await oauth_verifier.verify(request, dpop_verifier=dpop_verifier) + assert result is not None + assert result.token == "valid-access-token" + + +@pytest.mark.anyio +async def test_bearer_token_with_valid_dpop_proof( + oauth_verifier: OAuthTokenVerifier, + dpop_verifier: DPoPProofVerifier, + dpop_generator: DPoPProofGeneratorImpl, +) -> None: + """Bearer token with valid DPoP proof should pass verification.""" + proof = dpop_generator.generate_proof( + "GET", + "https://example.com/api/resource", + credential="valid-access-token", + ) + request = _make_request( + "GET", + "https://example.com/api/resource", + { + "Authorization": "Bearer valid-access-token", + "DPoP": proof, + }, + ) + result = await oauth_verifier.verify(request, dpop_verifier=dpop_verifier) + assert result is not None + assert result.token == "valid-access-token" + + +@pytest.mark.anyio +async def test_dpop_bound_token_requires_proof( + oauth_verifier: OAuthTokenVerifier, + dpop_verifier: DPoPProofVerifier, +) -> None: + """DPoP-bound token (Authorization: DPoP) without proof should fail.""" + request = _make_request( + "GET", + "https://example.com/api/resource", + {"Authorization": "DPoP valid-access-token"}, + ) + result = await oauth_verifier.verify(request, dpop_verifier=dpop_verifier) + assert result is None + + +@pytest.mark.anyio +async def test_dpop_bound_token_with_valid_proof( + oauth_verifier: OAuthTokenVerifier, + dpop_verifier: DPoPProofVerifier, + dpop_generator: DPoPProofGeneratorImpl, +) -> None: + """DPoP-bound token with valid proof should pass.""" + proof = dpop_generator.generate_proof( + "GET", + "https://example.com/api/resource", + credential="valid-access-token", + ) + request = _make_request( + "GET", + "https://example.com/api/resource", + { + "Authorization": "DPoP valid-access-token", + "DPoP": proof, + }, + ) + result = await oauth_verifier.verify(request, dpop_verifier=dpop_verifier) + assert result is not None + + +@pytest.mark.anyio +async def test_dpop_proof_method_mismatch_fails( + oauth_verifier: OAuthTokenVerifier, + dpop_verifier: DPoPProofVerifier, + dpop_generator: DPoPProofGeneratorImpl, +) -> None: + """DPoP proof with mismatched HTTP method should fail.""" + proof = dpop_generator.generate_proof( + "POST", # Wrong method + "https://example.com/api/resource", + credential="valid-access-token", + ) + request = _make_request( + "GET", # Actual request method + "https://example.com/api/resource", + { + "Authorization": "Bearer valid-access-token", + "DPoP": proof, + }, + ) + result = await oauth_verifier.verify(request, dpop_verifier=dpop_verifier) + assert result is None From 07ac1f8e8a4c633ee1168b97747af7577643526f Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 31 Jan 2026 21:58:27 +0800 Subject: [PATCH 22/64] feat(auth): add DPoP support to simple-auth-multiprotocol example Add --dpop-enabled CLI flag and integrate DPoPProofVerifier with MultiProtocolAuthBackend for end-to-end DPoP verification. --- .../multiprotocol.py | 34 ++++++++++++++++--- .../mcp_simple_auth_multiprotocol/server.py | 15 ++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py index 11e22150a..01f8f12b0 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py @@ -6,6 +6,7 @@ from starlette.authentication import AuthCredentials, AuthenticationBackend from starlette.requests import HTTPConnection, Request +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken from mcp.server.auth.verifiers import ( @@ -36,31 +37,54 @@ def build_multiprotocol_backend( oauth_token_verifier: Any, api_key_valid_keys: set[str], api_key_scopes: list[str] | None = None, -) -> MultiProtocolAuthBackend: - """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + dpop_enabled: bool = False, +) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers. + + Args: + oauth_token_verifier: Token verifier for OAuth introspection. + api_key_valid_keys: Set of valid API keys. + api_key_scopes: Scopes to grant for API key authentication. + dpop_enabled: Whether to enable DPoP proof verification. + + Returns: + Tuple of (MultiProtocolAuthBackend, DPoPProofVerifier or None). + """ oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) api_key_verifier = APIKeyVerifier( valid_keys=api_key_valid_keys, scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - return MultiProtocolAuthBackend( + backend = MultiProtocolAuthBackend( verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] ) + dpop_verifier: DPoPProofVerifier | None = None + if dpop_enabled: + dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + return backend, dpop_verifier + class MultiProtocolAuthBackendAdapter(AuthenticationBackend): """ Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend. Converts AccessToken from backend.verify() into (AuthCredentials, AuthenticatedUser). + Optionally verifies DPoP proofs when dpop_verifier is provided. """ - def __init__(self, backend: MultiProtocolAuthBackend) -> None: + def __init__( + self, + backend: MultiProtocolAuthBackend, + dpop_verifier: DPoPProofVerifier | None = None, + ) -> None: self._backend = backend + self._dpop_verifier = dpop_verifier async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - result = await self._backend.verify(cast(Request, conn)) + result = await self._backend.verify(cast(Request, conn), dpop_verifier=self._dpop_verifier) if result is None: return None if result.expires_at is not None and result.expires_at < int(time.time()): diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py index 03c92ebb1..045adb216 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -52,6 +52,7 @@ class ResourceServerSettings(BaseSettings): api_key_valid_keys: str = "demo-api-key-12345" default_protocol: str = "oauth2" protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + dpop_enabled: bool = False def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: @@ -92,10 +93,13 @@ def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> St validate_resource=settings.oauth_strict, ) api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend = build_multiprotocol_backend( - oauth_verifier, api_key_keys, api_key_scopes=[settings.mcp_scope] + backend, dpop_verifier = build_multiprotocol_backend( + oauth_verifier, + api_key_keys, + api_key_scopes=[settings.mcp_scope], + dpop_enabled=settings.dpop_enabled, ) - adapter = MultiProtocolAuthBackendAdapter(backend) + adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) fastmcp = FastMCP( name="MCP Resource Server (multiprotocol)", @@ -201,12 +205,14 @@ async def lifespan(app: Starlette): ) @click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") @click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") def main( port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool, api_keys: str, + dpop_enabled: bool, ) -> int: """Run the multi-protocol MCP Resource Server.""" logging.basicConfig(level=logging.INFO) @@ -221,6 +227,7 @@ def main( auth_server_introspection_endpoint=f"{auth_server}/introspect", oauth_strict=oauth_strict, api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, ) except ValueError as e: logger.error("Configuration error: %s", e) @@ -229,6 +236,8 @@ def main( app = create_multiprotocol_resource_server(settings) logger.info("Multi-protocol RS running on %s", settings.server_url) logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") uvicorn.run(app, host=settings.host, port=settings.port) return 0 From 3a3d9148b4acdebe09de3531f78cd4584e9f4f74 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sun, 1 Feb 2026 15:36:11 +0800 Subject: [PATCH 23/64] Add DPoP integration test script and multiprotocol example updates - Add scripts/run_phase4_dpop_integration_test.sh for automated DPoP tests - Update test_oauth2_protocol: expect OAuthFlowError when http_client is None - Update simple-auth-multiprotocol-client: OAuth+DPoP support, InMemoryStorage - Update simple-auth-multiprotocol: DPoP logging, oauth2 protocol_version 2.0 --- .../main.py | 199 +++++++++-- .../multiprotocol.py | 31 +- .../mcp_simple_auth_multiprotocol/server.py | 2 +- scripts/run_phase4_dpop_integration_test.sh | 311 ++++++++++++++++++ tests/client/auth/test_oauth2_protocol.py | 12 +- 5 files changed, 528 insertions(+), 27 deletions(-) create mode 100755 scripts/run_phase4_dpop_integration_test.sh diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py index 176efae41..5adb2e8b7 100644 --- a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -1,13 +1,19 @@ #!/usr/bin/env python3 -"""Multi-protocol MCP client: API Key + Mutual TLS (placeholder).""" +"""Multi-protocol MCP client: OAuth (with optional DPoP), API Key, Mutual TLS (placeholder).""" import asyncio import os +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any +from urllib.parse import parse_qs, urlparse import httpx from mcp.client.auth.multi_protocol import MultiProtocolAuthProvider, TokenStorage from mcp.client.auth.protocol import AuthContext, AuthProtocol +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol from mcp.client.auth.registry import AuthProtocolRegistry from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -15,22 +21,109 @@ APIKeyCredentials, AuthCredentials, AuthProtocolMetadata, + OAuthClientMetadata, OAuthToken, ProtectedResourceMetadata, ) +from pydantic import AnyHttpUrl class InMemoryStorage(TokenStorage): - """In-memory credential storage.""" + """In-memory credential storage supporting both AuthCredentials and OAuthToken. + + Also implements get_client_info/set_client_info for OAuth client registration storage. + """ def __init__(self) -> None: - self._creds: AuthCredentials | None = None + self._creds: AuthCredentials | OAuthToken | None = None + self._client_info: Any = None async def get_tokens(self) -> AuthCredentials | OAuthToken | None: return self._creds async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: - self._creds = tokens if isinstance(tokens, AuthCredentials) else None + self._creds = tokens + + async def get_client_info(self) -> Any: + """Get stored OAuth client information.""" + return self._client_info + + async def set_client_info(self, client_info: Any) -> None: + """Store OAuth client information.""" + self._client_info = client_info + + +class CallbackHandler(BaseHTTPRequestHandler): + """HTTP handler to capture OAuth callback.""" + + def __init__(self, request: Any, client_address: Any, server: Any, callback_data: dict[str, Any]): + self.callback_data = callback_data + super().__init__(request, client_address, server) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + if "code" in query_params: + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"

Authorization Successful!

You can close this window.

") + elif "error" in query_params: + self.callback_data["error"] = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(f"

Error

{query_params['error'][0]}

".encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + pass # Suppress logging + + +class CallbackServer: + """Server to handle OAuth callbacks.""" + + def __init__(self, port: int = 3031): + self.port = port + self.server: HTTPServer | None = None + self.thread: threading.Thread | None = None + self.callback_data: dict[str, Any] = {"authorization_code": None, "state": None, "error": None} + + def start(self) -> None: + callback_data = self.callback_data + + class DataHandler(CallbackHandler): + def __init__(self, request: Any, client_address: Any, server: Any): + super().__init__(request, client_address, server, callback_data) + + self.server = HTTPServer(("localhost", self.port), DataHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"Callback server started on http://localhost:{self.port}") + + def stop(self) -> None: + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout: int = 300) -> str: + start = time.time() + while time.time() - start < timeout: + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + if self.callback_data["error"]: + raise RuntimeError(f"OAuth error: {self.callback_data['error']}") + time.sleep(0.1) + raise RuntimeError("Timeout waiting for OAuth callback") + + def get_state(self) -> str | None: + return self.callback_data["state"] class ApiKeyProtocol: @@ -86,36 +179,87 @@ async def discover_metadata( def _register_protocols() -> None: + AuthProtocolRegistry.register("oauth2", OAuth2Protocol) AuthProtocolRegistry.register("api_key", ApiKeyProtocol) AuthProtocolRegistry.register("mutual_tls", MutualTlsPlaceholderProtocol) class SimpleAuthMultiprotocolClient: - """MCP client with multi-protocol auth (API Key + mTLS placeholder).""" + """MCP client with multi-protocol auth (OAuth + DPoP, API Key, mTLS placeholder).""" - def __init__(self, server_url: str) -> None: + def __init__(self, server_url: str, use_oauth: bool = False, dpop_enabled: bool = False) -> None: self.server_url = server_url + self.use_oauth = use_oauth + self.dpop_enabled = dpop_enabled self.session: ClientSession | None = None async def connect(self) -> None: _register_protocols() - api_key = os.getenv("MCP_API_KEY", "demo-api-key-12345") storage = InMemoryStorage() - protocols: list[AuthProtocol] = [ - ApiKeyProtocol(api_key=api_key), - MutualTlsPlaceholderProtocol(), - ] - auth = MultiProtocolAuthProvider( - server_url=self.server_url.rstrip("/").replace("/mcp", ""), - storage=storage, - protocols=protocols, - ) - async with httpx.AsyncClient(auth=auth, follow_redirects=True) as http_client: - async with streamable_http_client( - url=self.server_url, - http_client=http_client, - ) as (read_stream, write_stream, get_session_id): - await self._run_session(read_stream, write_stream, get_session_id) + protocols: list[AuthProtocol] = [] + + callback_server: CallbackServer | None = None + + if self.use_oauth: + # Setup OAuth with optional DPoP + callback_server = CallbackServer(port=3031) + callback_server.start() + + async def callback_handler() -> tuple[str, str | None]: + print("Waiting for OAuth authorization...") + try: + code = callback_server.wait_for_callback(timeout=300) + return code, callback_server.get_state() + finally: + callback_server.stop() + + async def redirect_handler(url: str) -> None: + print(f"Opening browser for authorization: {url}") + webbrowser.open(url) + + client_metadata = OAuthClientMetadata( + client_name="Multi-protocol Auth Client", + redirect_uris=[AnyHttpUrl("http://localhost:3031/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ) + + oauth_protocol = OAuth2Protocol( + client_metadata=client_metadata, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + dpop_enabled=self.dpop_enabled, + ) + protocols.append(oauth_protocol) + print(f"OAuth protocol enabled (DPoP: {self.dpop_enabled})") + + # Always add API Key and mTLS as fallback + api_key = os.getenv("MCP_API_KEY", "demo-api-key-12345") + protocols.append(ApiKeyProtocol(api_key=api_key)) + protocols.append(MutualTlsPlaceholderProtocol()) + + try: + # Create http_client first, then pass it to auth provider + # This allows OAuth discovery to work (requires http_client for PRM fetch) + async with httpx.AsyncClient(follow_redirects=True) as http_client: + auth = MultiProtocolAuthProvider( + server_url=self.server_url.rstrip("/").replace("/mcp", ""), + storage=storage, + protocols=protocols, + http_client=http_client, + dpop_enabled=self.dpop_enabled, + ) + # Set auth on client after creation + http_client.auth = auth + + async with streamable_http_client( + url=self.server_url, + http_client=http_client, + ) as (read_stream, write_stream, get_session_id): + await self._run_session(read_stream, write_stream, get_session_id) + finally: + if callback_server: + callback_server.stop() async def _run_session(self, read_stream: Any, write_stream: Any, get_session_id: Any) -> None: print("Initializing MCP session...") @@ -196,8 +340,17 @@ async def _interactive_loop(self) -> None: async def main() -> None: server_url = os.getenv("MCP_SERVER_URL", "http://localhost:8002/mcp") + use_oauth = os.getenv("MCP_USE_OAUTH", "").lower() in ("1", "true", "yes") + dpop_enabled = os.getenv("MCP_DPOP_ENABLED", "").lower() in ("1", "true", "yes") + print(f"Connecting to {server_url}...") - client = SimpleAuthMultiprotocolClient(server_url) + print(f" OAuth: {'enabled' if use_oauth else 'disabled'}") + print(f" DPoP: {'enabled' if dpop_enabled else 'disabled'}") + + if dpop_enabled and not use_oauth: + print(" Warning: DPoP requires OAuth enabled (MCP_USE_OAUTH=1) to take effect") + + client = SimpleAuthMultiprotocolClient(server_url, use_oauth=use_oauth, dpop_enabled=dpop_enabled) try: await client.connect() except Exception as e: diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py index 01f8f12b0..3ddf868b5 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py @@ -1,5 +1,6 @@ """Multi-protocol auth: adapter for Starlette and Mutual TLS placeholder verifier.""" +import logging import time from typing import Any, cast @@ -16,6 +17,8 @@ OAuthTokenVerifier, ) +logger = logging.getLogger(__name__) + class MutualTLSVerifier: """ @@ -84,11 +87,37 @@ def __init__( self._dpop_verifier = dpop_verifier async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - result = await self._backend.verify(cast(Request, conn), dpop_verifier=self._dpop_verifier) + request = cast(Request, conn) + + # Log DPoP status + dpop_header = request.headers.get("dpop") + if self._dpop_verifier is not None: + if dpop_header: + logger.info("DPoP proof present, verification enabled") + else: + logger.debug("DPoP verification enabled but no DPoP header in request") + elif dpop_header: + logger.debug("DPoP header present but verification not enabled (ignoring)") + + result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) + if result is None: + if dpop_header and self._dpop_verifier is not None: + logger.warning("Authentication failed (DPoP proof may be invalid)") + else: + logger.debug("Authentication failed (no valid credentials)") return None + if result.expires_at is not None and result.expires_at < int(time.time()): + logger.warning("Token expired for client_id=%s", result.client_id) return None + + # Log successful authentication + if dpop_header and self._dpop_verifier is not None: + logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) + else: + logger.info("Authentication successful (client_id=%s)", result.client_id) + return ( AuthCredentials(result.scopes or []), AuthenticatedUser(result), diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py index 045adb216..bdc0d311e 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -62,7 +62,7 @@ def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtoc return [ AuthProtocolMetadata( protocol_id="oauth2", - protocol_version="1.0", + protocol_version="2.0", metadata_url=oauth_metadata_url, scopes_supported=[settings.mcp_scope], ), diff --git a/scripts/run_phase4_dpop_integration_test.sh b/scripts/run_phase4_dpop_integration_test.sh new file mode 100755 index 000000000..19c387da2 --- /dev/null +++ b/scripts/run_phase4_dpop_integration_test.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +# Phase 4 DPoP integration test: start simple-auth (AS) and simple-auth-multiprotocol (RS with DPoP), +# then run automated DPoP verification tests and optional OAuth+DPoP manual test. +# +# Usage: from repo root, run: ./scripts/run_phase4_dpop_integration_test.sh +# +# Env variables: +# MCP_RS_PORT - Resource Server port (default: 8002) +# MCP_AS_PORT - Authorization Server port (default: 9000) +# MCP_SKIP_OAUTH - Set to 1 to skip OAuth+DPoP manual test (default: run all) +# +# Test matrix: +# B2: API Key authentication (DPoP should not affect) +# A2: Bearer token without DPoP proof (should fail) +# A1: OAuth + DPoP (requires browser authorization) +# DPoP negative tests: wrong method, wrong URI, fake token + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" +MULTIPROTOCOL_SERVER="${REPO_ROOT}/examples/servers/simple-auth-multiprotocol" +MULTIPROTOCOL_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-multiprotocol-client" +RS_PORT="${MCP_RS_PORT:-8002}" +AS_PORT="${MCP_AS_PORT:-9000}" +API_KEY="dpop-test-api-key-12345" +SKIP_OAUTH="${MCP_SKIP_OAUTH:-0}" + +cd "$REPO_ROOT" +echo "============================================================" +echo "Phase 4 DPoP Integration Test" +echo "============================================================" +echo "Repo root: $REPO_ROOT" +echo "AS port: $AS_PORT" +echo "RS port: $RS_PORT" +echo "API Key: $API_KEY" +echo "Skip OAuth: $SKIP_OAUTH" +echo "" + +uv sync --quiet 2>/dev/null || true + +wait_for_url() { + local url="$1" + local name="$2" + local max=30 + local n=0 + while ! curl -sSf -o /dev/null "$url" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -ge "$max" ]; then + echo "Timeout waiting for $name at $url" + return 1 + fi + sleep 0.5 + done + echo "$name is up at $url" +} + +cleanup() { + echo "" + echo "Stopping servers..." + [ -n "$AS_PID" ] && kill "$AS_PID" 2>/dev/null || true + [ -n "$RS_PID" ] && kill "$RS_PID" 2>/dev/null || true + wait 2>/dev/null || true + echo "Cleanup done." +} +trap cleanup EXIT + +# Start Authorization Server +echo "Starting Authorization Server..." +cd "$SIMPLE_AUTH_SERVER" +uv run mcp-simple-auth-as --port="$AS_PORT" & +AS_PID=$! +cd "$REPO_ROOT" +wait_for_url "http://localhost:${AS_PORT}/.well-known/oauth-authorization-server" "Authorization Server" + +# Start Resource Server with DPoP enabled +echo "Starting Resource Server with DPoP enabled..." +cd "$MULTIPROTOCOL_SERVER" +uv run mcp-simple-auth-multiprotocol-rs \ + --port="$RS_PORT" \ + --auth-server="http://localhost:${AS_PORT}" \ + --api-keys="$API_KEY" \ + --dpop-enabled & +RS_PID=$! +cd "$REPO_ROOT" +wait_for_url "http://localhost:${RS_PORT}/.well-known/oauth-protected-resource/mcp" "Resource Server (PRM)" + +echo "" +echo "PRM (Protected Resource Metadata):" +curl -sS "http://localhost:${RS_PORT}/.well-known/oauth-protected-resource/mcp" | python3 -m json.tool 2>/dev/null | head -30 || \ + curl -sS "http://localhost:${RS_PORT}/.well-known/oauth-protected-resource/mcp" | head -c 600 +echo "" + +MCP_ENDPOINT="http://localhost:${RS_PORT}/mcp" +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + local expected_status="$2" + local actual_status="$3" + + if [ "$actual_status" = "$expected_status" ]; then + echo " PASS: $name (status=$actual_status)" + PASSED=$((PASSED + 1)) + else + echo " FAIL: $name (expected=$expected_status, got=$actual_status)" + FAILED=$((FAILED + 1)) + fi +} + +echo "============================================================" +echo "Running Automated DPoP Tests" +echo "============================================================" +echo "" + +# Test B2: API Key Authentication +echo "[Test B2] API Key Authentication (DPoP should not affect)" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "X-API-Key: $API_KEY" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "API Key auth works with DPoP enabled" "200" "$STATUS" + +# Test: No Authentication +echo "[Test] No Authentication (expect 401)" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "No auth returns 401" "401" "$STATUS" + +# Test: Check WWW-Authenticate header +echo "[Test] WWW-Authenticate header presence" +WWW_AUTH=$(curl -s -D - -o /dev/null -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}' 2>&1 | grep -i "www-authenticate" || echo "") +if [ -n "$WWW_AUTH" ]; then + echo " PASS: WWW-Authenticate header present" + PASSED=$((PASSED + 1)) +else + echo " FAIL: WWW-Authenticate header missing" + FAILED=$((FAILED + 1)) +fi + +# Test A2: Bearer token without DPoP proof (fake token) +echo "[Test A2] Bearer token without DPoP proof (fake token)" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer fake-bearer-token-12345" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "Bearer without DPoP rejected" "401" "$STATUS" + +# Generate DPoP proof using Python helper (uses uv run to ensure correct venv) +generate_dpop_proof() { + local method="$1" + local uri="$2" + local token="$3" + cd "$REPO_ROOT" + uv run python3 -c " +import hashlib +import base64 +import time +import uuid +import jwt +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +# Generate key pair +private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) +public_key = private_key.public_key() +public_numbers = public_key.public_numbers() +x_bytes = public_numbers.x.to_bytes(32, byteorder='big') +y_bytes = public_numbers.y.to_bytes(32, byteorder='big') + +jwk = { + 'kty': 'EC', + 'crv': 'P-256', + 'x': base64.urlsafe_b64encode(x_bytes).rstrip(b'=').decode('ascii'), + 'y': base64.urlsafe_b64encode(y_bytes).rstrip(b'=').decode('ascii'), +} + +claims = { + 'jti': str(uuid.uuid4()), + 'htm': '$method', + 'htu': '$uri', + 'iat': int(time.time()), +} + +token = '$token' +if token: + token_hash = hashlib.sha256(token.encode('ascii')).digest() + claims['ath'] = base64.urlsafe_b64encode(token_hash).rstrip(b'=').decode('ascii') + +header = {'typ': 'dpop+jwt', 'alg': 'ES256', 'jwk': jwk} +private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), +) +proof = jwt.encode(claims, private_pem, algorithm='ES256', headers=header) +print(proof) +" +} + +# Test: DPoP proof with fake token +echo "[Test] DPoP proof with fake token (expect 401)" +FAKE_TOKEN="fake-access-token-12345" +DPOP_PROOF=$(generate_dpop_proof "POST" "$MCP_ENDPOINT" "$FAKE_TOKEN") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: DPoP $FAKE_TOKEN" \ + -H "DPoP: $DPOP_PROOF" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "DPoP with fake token rejected" "401" "$STATUS" + +# Test: DPoP proof with wrong HTTP method (htm mismatch) +echo "[Test] DPoP proof wrong method (htm=GET for POST request)" +DPOP_PROOF_WRONG_METHOD=$(generate_dpop_proof "GET" "$MCP_ENDPOINT" "$FAKE_TOKEN") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: DPoP $FAKE_TOKEN" \ + -H "DPoP: $DPOP_PROOF_WRONG_METHOD" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "DPoP htm mismatch rejected" "401" "$STATUS" + +# Test: DPoP proof with wrong URI (htu mismatch) +echo "[Test] DPoP proof wrong URI (htu mismatch)" +DPOP_PROOF_WRONG_URI=$(generate_dpop_proof "POST" "http://localhost:9999/wrong" "$FAKE_TOKEN") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: DPoP $FAKE_TOKEN" \ + -H "DPoP: $DPOP_PROOF_WRONG_URI" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "DPoP htu mismatch rejected" "401" "$STATUS" + +# Test: DPoP proof without Authorization header +echo "[Test] DPoP proof without Authorization header (expect 401)" +DPOP_PROOF_NO_TOKEN=$(generate_dpop_proof "POST" "$MCP_ENDPOINT" "") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "DPoP: $DPOP_PROOF_NO_TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') +run_test "DPoP proof without token rejected" "401" "$STATUS" + +echo "" +echo "============================================================" +echo "Automated Test Summary" +echo "============================================================" +echo " Passed: $PASSED" +echo " Failed: $FAILED" +echo "" + +if [ "$FAILED" -gt 0 ]; then + echo "WARNING: Some automated tests failed!" +fi + +# A1: OAuth + DPoP manual test +if [ "$SKIP_OAUTH" = "1" ]; then + echo "Skipping OAuth+DPoP manual test (MCP_SKIP_OAUTH=1)" + echo "" + echo "============================================================" + echo "Final Result: $PASSED passed, $FAILED failed (automated only)" + echo "============================================================" +else + echo "" + echo "============================================================" + echo "[Test A1] OAuth + DPoP Manual Test" + echo "============================================================" + echo "" + echo "This test requires browser authorization." + echo "The client will:" + echo " 1. Open your browser for OAuth authorization" + echo " 2. After authorization, connect with DPoP-bound access token" + echo " 3. You should see 'DPoP proof present, verification enabled' in server logs" + echo "" + echo "At the mcp> prompt, run:" + echo " list - List available tools" + echo " call get_time {} - Call the get_time tool" + echo " quit - Exit the client" + echo "" + echo "Expected: All commands should succeed with DPoP authentication." + echo "" + read -p "Press Enter to start OAuth+DPoP test (or Ctrl+C to skip)..." + echo "" + + cd "$MULTIPROTOCOL_CLIENT" + MCP_SERVER_URL="$MCP_ENDPOINT" \ + MCP_USE_OAUTH=1 \ + MCP_DPOP_ENABLED=1 \ + uv run mcp-simple-auth-multiprotocol-client + + echo "" + echo "============================================================" + echo "Manual Test Complete" + echo "============================================================" + echo "Did the OAuth+DPoP test succeed? (list/call commands worked?)" + echo "Check server logs for: 'Authentication successful with DPoP'" + echo "" + echo "Final Result: $PASSED passed, $FAILED failed (automated)" + echo " + A1 OAuth+DPoP (manual verification required)" + echo "============================================================" +fi diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index 777f94d36..e8515fbd1 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -149,10 +149,15 @@ async def test_discover_metadata_from_prm_returns_oauth2_entry( @pytest.mark.anyio -async def test_authenticate_requires_http_client( +async def test_authenticate_creates_own_http_client( oauth2_protocol: OAuth2Protocol, client_metadata: OAuthClientMetadata, ) -> None: + """OAuth2Protocol.authenticate creates its own httpx client, so context.http_client can be None. + + This tests that the method doesn't crash when http_client is None. + It will still fail during OAuth discovery (no server running), but that's expected. + """ context = AuthContext( server_url="https://example.com", storage=None, @@ -165,7 +170,10 @@ async def test_authenticate_requires_http_client( protected_resource_metadata=None, scope_from_www_auth=None, ) - with pytest.raises(ValueError, match="context.http_client"): + # Now authenticate creates its own client, so it won't raise ValueError for http_client=None + # It will fail during OAuth discovery since there's no server, which is expected + from mcp.client.auth.exceptions import OAuthFlowError + with pytest.raises(OAuthFlowError, match="Could not discover"): await oauth2_protocol.authenticate(context) From cb5f638dc5b2d727969fbea289f71b307ad16ae7 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sun, 1 Feb 2026 15:39:11 +0800 Subject: [PATCH 24/64] Refactor OAuth 401/403 flow with shared generator to fix deadlock - Add _oauth_401_flow.py: oauth_401_flow_generator, oauth_403_flow_generator - Refactor OAuthClientProvider.async_auth_flow to drive shared generators - Refactor MultiProtocolAuthProvider 401 handling to use shared OAuth flow - Fix OAuth2Protocol.authenticate to use fresh client when called outside flow - Fix OAuthTokenVerifier: use scope for HTTP method (HTTPConnection compat) --- src/mcp/client/auth/_oauth_401_flow.py | 163 ++++++++++++++++ src/mcp/client/auth/multi_protocol.py | 241 ++++++++++++++---------- src/mcp/client/auth/oauth2.py | 111 ++--------- src/mcp/client/auth/protocols/oauth2.py | 26 +-- src/mcp/server/auth/verifiers.py | 3 +- 5 files changed, 342 insertions(+), 202 deletions(-) create mode 100644 src/mcp/client/auth/_oauth_401_flow.py diff --git a/src/mcp/client/auth/_oauth_401_flow.py b/src/mcp/client/auth/_oauth_401_flow.py new file mode 100644 index 000000000..8187a3bd2 --- /dev/null +++ b/src/mcp/client/auth/_oauth_401_flow.py @@ -0,0 +1,163 @@ +""" +共享的 OAuth 401/403 流程 generator。 + +供 OAuthClientProvider 与 MultiProtocolAuthProvider 复用,通过 yield 发送请求, +实现单 client、无死锁的 OAuth 发现与认证流程。 +""" + +import logging +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any, Protocol + +import httpx + +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + create_client_info_from_metadata_url, + create_client_registration_request, + create_oauth_metadata_request, + extract_field_from_www_auth, + extract_resource_metadata_from_www_auth, + extract_scope_from_www_auth, + get_client_metadata_scopes, + handle_auth_metadata_response, + handle_protected_resource_response, + handle_registration_response, + should_use_client_metadata_url, +) + +if TYPE_CHECKING: + from mcp.shared.auth import ProtectedResourceMetadata + + +class _OAuth401FlowProvider(Protocol): + """Provider interface for oauth_401_flow_generator (OAuthClientProvider duck type).""" + + @property + def context(self) -> Any: + ... + + async def _perform_authorization(self) -> httpx.Request: + ... + + async def _handle_token_response(self, response: httpx.Response) -> None: + ... + + +logger = logging.getLogger(__name__) + + +async def oauth_401_flow_generator( + provider: _OAuth401FlowProvider, + request: httpx.Request, + response_401: httpx.Response, + *, + initial_prm: "ProtectedResourceMetadata | None" = None, +) -> AsyncGenerator[httpx.Request, httpx.Response]: + """ + OAuth 401 流程:PRM 发现(可跳过)→ AS 发现 → scope → 注册/CIMD → 授权码 → Token 交换。 + + 通过 yield 发出请求,由调用方负责发送并传回响应。供 OAuthClientProvider 与 + MultiProtocolAuthProvider 复用,实现单 client、yield 模式的 OAuth 流程。 + + Args: + provider: OAuthClientProvider 实例,需有 context、_perform_authorization、_handle_token_response + request: 触发 401 的原始请求 + response_401: 401 响应 + initial_prm: 若提供则跳过 PRM 发现(MultiProtocolAuthProvider 已事先完成) + """ + ctx = provider.context + + if initial_prm is not None: + ctx.protected_resource_metadata = initial_prm + if initial_prm.authorization_servers: + ctx.auth_server_url = str(initial_prm.authorization_servers[0]) + else: + # Step 1: Discover protected resource metadata (SEP-985 with fallback support) + www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response_401) + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( + www_auth_resource_metadata_url, ctx.server_url + ) + + for url in prm_discovery_urls: + discovery_request = create_oauth_metadata_request(url) + discovery_response = yield discovery_request + + prm = await handle_protected_resource_response(discovery_response) + if prm: + ctx.protected_resource_metadata = prm + assert len(prm.authorization_servers) > 0 + ctx.auth_server_url = str(prm.authorization_servers[0]) + break + logger.debug("Protected resource metadata discovery failed: %s", url) + + # Step 2: Discover OAuth Authorization Server Metadata (OASM) + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + ctx.auth_server_url, ctx.server_url + ) + + for url in asm_discovery_urls: + oauth_metadata_request = create_oauth_metadata_request(url) + oauth_metadata_response = yield oauth_metadata_request + + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if not ok: + break + if asm: + ctx.oauth_metadata = asm + break + logger.debug("OAuth metadata discovery failed: %s", url) + + # Step 3: Apply scope selection strategy + ctx.client_metadata.scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response_401), + ctx.protected_resource_metadata, + ctx.oauth_metadata, + ) + + # Step 4: Register client or use URL-based client ID (CIMD) + if not ctx.client_info: + if should_use_client_metadata_url(ctx.oauth_metadata, ctx.client_metadata_url): + logger.debug("Using URL-based client ID (CIMD): %s", ctx.client_metadata_url) + client_information = create_client_info_from_metadata_url( + ctx.client_metadata_url, # type: ignore[arg-type] + redirect_uris=ctx.client_metadata.redirect_uris, + ) + ctx.client_info = client_information + await ctx.storage.set_client_info(client_information) + else: + registration_request = create_client_registration_request( + ctx.oauth_metadata, + ctx.client_metadata, + ctx.get_authorization_base_url(ctx.server_url), + ) + registration_response = yield registration_request + client_information = await handle_registration_response(registration_response) + ctx.client_info = client_information + await ctx.storage.set_client_info(client_information) + + # Step 5: Perform authorization and complete token exchange + token_request = await provider._perform_authorization() # type: ignore[reportPrivateUsage] + token_response = yield token_request + await provider._handle_token_response(token_response) # type: ignore[reportPrivateUsage] + + +async def oauth_403_flow_generator( + provider: _OAuth401FlowProvider, + request: httpx.Request, + response_403: httpx.Response, +) -> AsyncGenerator[httpx.Request, httpx.Response]: + """ + OAuth 403 insufficient_scope 流程:更新 scope → 重新授权 → Token 交换。 + """ + ctx = provider.context + error = extract_field_from_www_auth(response_403, "error") + + if error == "insufficient_scope": + ctx.client_metadata.scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response_403), ctx.protected_resource_metadata + ) + token_request = await provider._perform_authorization() # type: ignore[reportPrivateUsage] + token_response = yield token_request + await provider._handle_token_response(token_response) # type: ignore[reportPrivateUsage] diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index d0b90450c..1f0cf1ea8 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -2,21 +2,41 @@ 多协议认证提供者。 提供基于协议注册表与发现的统一 HTTP 认证流程,支持 OAuth 2.0、API Key 等协议。 + +TokenStorage 双契约与转换约定 +---------------------------- +- **oauth2 契约**(OAuthClientProvider 使用):get_tokens() -> OAuthToken | None, + set_tokens(OAuthToken);另可有 get_client_info/set_client_info。 +- **multi_protocol 契约**(本模块 TokenStorage):get_tokens() -> AuthCredentials | OAuthToken | None, + set_tokens(AuthCredentials | OAuthToken)。 +- **转换约定**:MultiProtocolAuthProvider 在调用方做转换,不扩展协议方法: + - 取回时:_get_credentials() 调用 storage.get_tokens(),若得到 OAuthToken 则经 + _oauth_token_to_credentials 转为 OAuthCredentials。 + - 写入时:401 流程得到 AuthCredentials 后经 _credentials_to_storage 转为 + OAuthToken(仅 OAuthCredentials 转 OAuthToken,其他凭证原样),再调用 storage.set_tokens(to_store)。 +- 因此仅实现 get_tokens/set_tokens(OAuthToken) 的旧存储可直接用于 MultiProtocolAuthProvider, + 无需改存储实现。可选使用 OAuthTokenStorageAdapter 将此类存储包装为满足 multi_protocol 契约。 """ +import json import logging import time from collections.abc import AsyncGenerator -from typing import Any, Protocol +from typing import Any, Protocol, cast +from urllib.parse import urljoin import anyio import httpx +from pydantic import ValidationError +from mcp.client.auth._oauth_401_flow import oauth_401_flow_generator +from mcp.client.auth.oauth2 import OAuthClientProvider, TokenStorage as OAuth2TokenStorage from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol from mcp.client.auth.registry import AuthProtocolRegistry +from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.auth.utils import ( build_protected_resource_metadata_discovery_urls, - discover_authorization_servers, + create_oauth_metadata_request, extract_auth_protocols_from_www_auth, extract_default_protocol_from_www_auth, extract_field_from_www_auth, @@ -180,99 +200,25 @@ def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) ) request.headers["DPoP"] = proof - async def _fetch_prm( - self, resource_metadata_url: str | None, server_url: str - ) -> ProtectedResourceMetadata | None: - """按 SEP-985 顺序请求 PRM:先 resource_metadata,再 well-known 回退。""" - if not self._http_client: - return None - urls = build_protected_resource_metadata_discovery_urls( - resource_metadata_url, server_url - ) - for url in urls: + async def _parse_protocols_from_discovery_response( + self, response: httpx.Response, prm: ProtectedResourceMetadata | None + ) -> list[AuthProtocolMetadata]: + """解析 .well-known/authorization_servers 响应,回退到 PRM。""" + if response.status_code == 200: try: - resp = await self._http_client.get(url) - prm = await handle_protected_resource_response(resp) - if prm is not None: - return prm - except Exception as e: - logger.debug("PRM discovery failed for %s: %s", url, e) - return None - - async def _discover_and_authenticate( - self, request: httpx.Request, response: httpx.Response - ) -> None: - """ - 根据 401 响应进行协议发现与认证,并将新凭证写入 storage。 - - 流程:解析 WWW-Authenticate → 可选获取 PRM → 发现协议列表 → - 注册表选择协议 → 协议 authenticate → 写回 storage(含 OAuth 凭证转 OAuthToken 适配)。 - """ - resource_metadata_url = extract_resource_metadata_from_www_auth(response) - auth_protocols_header = extract_auth_protocols_from_www_auth(response) - default_protocol = extract_default_protocol_from_www_auth(response) - protocol_preferences = extract_protocol_preferences_from_www_auth(response) - - server_url = str(request.url) - prm: ProtectedResourceMetadata | None = None - protocols_metadata: list[AuthProtocolMetadata] = [] - - if self._http_client: - prm = await self._fetch_prm(resource_metadata_url, server_url) - protocols_metadata = await discover_authorization_servers( - server_url, self._http_client, prm - ) - - available = ( - [m.protocol_id for m in protocols_metadata] - if protocols_metadata - else (auth_protocols_header or []) - ) - if not available: - logger.debug("No available protocols from discovery or WWW-Authenticate") - return - - selected_id = AuthProtocolRegistry.select_protocol( - available, default_protocol, protocol_preferences - ) - if not selected_id: - logger.debug("No supported protocol selected for %s", available) - return - - protocol = self._get_protocol(selected_id) - if not protocol: - logger.debug("Protocol %s not in provider", selected_id) - return - - protocol_metadata: AuthProtocolMetadata | None = None - if protocols_metadata: - for m in protocols_metadata: - if m.protocol_id == selected_id: - protocol_metadata = m - break - - context = AuthContext( - server_url=server_url, - storage=self.storage, - protocol_id=selected_id, - protocol_metadata=protocol_metadata, - current_credentials=None, - dpop_storage=self.dpop_storage, - dpop_enabled=self.dpop_enabled, - http_client=self._http_client, - resource_metadata_url=resource_metadata_url, - protected_resource_metadata=prm, - scope_from_www_auth=extract_scope_from_www_auth(response), - ) - credentials = await protocol.authenticate(context) - to_store = _credentials_to_storage(credentials) - await self.storage.set_tokens(to_store) - - async def _handle_401_response( - self, response: httpx.Response, request: httpx.Request - ) -> None: - """处理 401:解析 WWW-Authenticate,触发发现与认证(骨架),便于后续重试。""" - await self._discover_and_authenticate(request, response) + content = await response.aread() + data = json.loads(content.decode()) + raw = data.get("protocols") + protocols_data: list[dict[str, Any]] = ( + cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] + ) + if protocols_data: + return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] + except (ValidationError, ValueError, KeyError, TypeError) as e: + logger.debug("Unified authorization_servers parse failed: %s", e) + if prm is not None and prm.mcp_auth_protocols: + return list(prm.mcp_auth_protocols) + return [] async def _handle_403_response( self, response: httpx.Response, request: httpx.Request @@ -303,7 +249,112 @@ async def async_auth_flow( if response.status_code == 401: async with self._lock: - await self._handle_401_response(response, request) + resource_metadata_url = extract_resource_metadata_from_www_auth(response) + auth_protocols_header = extract_auth_protocols_from_www_auth(response) + default_protocol = extract_default_protocol_from_www_auth(response) + protocol_preferences = extract_protocol_preferences_from_www_auth(response) + server_url = str(request.url) + + # Step 1: PRM discovery (yield) + prm: ProtectedResourceMetadata | None = None + prm_urls = build_protected_resource_metadata_discovery_urls( + resource_metadata_url, server_url + ) + for url in prm_urls: + prm_req = create_oauth_metadata_request(url) + prm_resp = yield prm_req + prm = await handle_protected_resource_response(prm_resp) + if prm is not None: + break + + # Step 2: Protocol discovery (yield) + discovery_url = urljoin( + server_url.rstrip("/") + "/", + ".well-known/authorization_servers", + ) + discovery_req = create_oauth_metadata_request(discovery_url) + discovery_resp = yield discovery_req + protocols_metadata = await self._parse_protocols_from_discovery_response( + discovery_resp, prm + ) + + available = ( + [m.protocol_id for m in protocols_metadata] + if protocols_metadata + else (auth_protocols_header or []) + ) + if not available: + logger.debug("No available protocols from discovery or WWW-Authenticate") + else: + selected_id = AuthProtocolRegistry.select_protocol( + available, default_protocol, protocol_preferences + ) + if selected_id: + protocol = self._get_protocol(selected_id) + if protocol: + protocol_metadata = None + if protocols_metadata: + for m in protocols_metadata: + if m.protocol_id == selected_id: + protocol_metadata = m + break + + if selected_id == "oauth2": + # OAuth: drive shared generator (single client, yield) + oauth_protocol = protocol + provider = OAuthClientProvider( + server_url=server_url, + client_metadata=getattr( + oauth_protocol, "_client_metadata" + ), + storage=cast(OAuth2TokenStorage, self.storage), + redirect_handler=getattr( + oauth_protocol, "_redirect_handler", None + ), + callback_handler=getattr( + oauth_protocol, "_callback_handler", None + ), + timeout=getattr( + oauth_protocol, "_timeout", self.timeout + ), + client_metadata_url=getattr( + oauth_protocol, "_client_metadata_url", None + ), + ) + provider.context.protocol_version = request.headers.get( + MCP_PROTOCOL_VERSION + ) + gen = oauth_401_flow_generator( + provider, request, response, initial_prm=prm + ) + auth_req = await gen.__anext__() + while True: + auth_resp = yield auth_req + try: + auth_req = await gen.asend(auth_resp) + except StopAsyncIteration: + break + else: + # API Key, mTLS, etc.: call protocol.authenticate + context = AuthContext( + server_url=server_url, + storage=self.storage, + protocol_id=selected_id, + protocol_metadata=protocol_metadata, + current_credentials=None, + dpop_storage=self.dpop_storage, + dpop_enabled=self.dpop_enabled, + http_client=self._http_client, + resource_metadata_url=resource_metadata_url, + protected_resource_metadata=prm, + scope_from_www_auth=extract_scope_from_www_auth( + response + ), + ) + credentials = await protocol.authenticate(context) + to_store = _credentials_to_storage(credentials) + await self.storage.set_tokens(to_store) + credentials = await self._get_credentials() if credentials and self._is_credentials_valid(credentials): await self._ensure_dpop_initialized(credentials) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 597284c36..7e4ddb4c6 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -18,6 +18,7 @@ import httpx from pydantic import BaseModel, Field, ValidationError +from mcp.client.auth._oauth_401_flow import oauth_401_flow_generator, oauth_403_flow_generator from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, @@ -26,8 +27,6 @@ create_client_registration_request, create_oauth_metadata_request, extract_field_from_www_auth, - extract_resource_metadata_from_www_auth, - extract_scope_from_www_auth, get_client_metadata_scopes, handle_auth_metadata_response, handle_protected_resource_response, @@ -589,114 +588,36 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. response = yield request if response.status_code == 401: - # Perform full OAuth flow try: - # OAuth flow must be inline due to generator constraints - www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) - - # Step 1: Discover protected resource metadata (SEP-985 with fallback support) - prm_discovery_urls = build_protected_resource_metadata_discovery_urls( - www_auth_resource_metadata_url, self.context.server_url - ) - - for url in prm_discovery_urls: # pragma: no branch - discovery_request = create_oauth_metadata_request(url) - - discovery_response = yield discovery_request # sending request - - prm = await handle_protected_resource_response(discovery_response) - if prm: - self.context.protected_resource_metadata = prm - - # todo: try all authorization_servers to find the OASM - assert ( - len(prm.authorization_servers) > 0 - ) # this is always true as authorization_servers has a min length of 1 - - self.context.auth_server_url = str(prm.authorization_servers[0]) + gen = oauth_401_flow_generator(self, request, response) + auth_request = await gen.__anext__() + while True: + auth_response = yield auth_request + try: + auth_request = await gen.asend(auth_response) + except StopAsyncIteration: break - else: - logger.debug(f"Protected resource metadata discovery failed: {url}") - - asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( - self.context.auth_server_url, self.context.server_url - ) - - # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) - for url in asm_discovery_urls: # pragma: no branch - oauth_metadata_request = create_oauth_metadata_request(url) - oauth_metadata_response = yield oauth_metadata_request - - ok, asm = await handle_auth_metadata_response(oauth_metadata_response) - if not ok: - break - if ok and asm: - self.context.oauth_metadata = asm - break - else: - logger.debug(f"OAuth metadata discovery failed: {url}") - - # Step 3: Apply scope selection strategy - self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), - self.context.protected_resource_metadata, - self.context.oauth_metadata, - ) - - # 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( - self.context.oauth_metadata, - self.context.client_metadata, - self.context.get_authorization_base_url(self.context.server_url), - ) - registration_response = yield registration_request - client_information = await handle_registration_response(registration_response) - self.context.client_info = client_information - await self.context.storage.set_client_info(client_information) - - # Step 5: Perform authorization and complete token exchange - token_response = yield await self._perform_authorization() - await self._handle_token_response(token_response) except Exception: # pragma: no cover logger.exception("OAuth flow error") raise - # Retry with new tokens self._add_auth_header(request) yield request elif response.status_code == 403: - # Step 1: Extract error field from WWW-Authenticate header error = extract_field_from_www_auth(response, "error") - - # Step 2: Check if we need to step-up authorization if error == "insufficient_scope": # pragma: no branch try: - # Step 2a: Update the required scopes - self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), self.context.protected_resource_metadata - ) - - # Step 2b: Perform (re-)authorization and token exchange - token_response = yield await self._perform_authorization() - await self._handle_token_response(token_response) + gen = oauth_403_flow_generator(self, request, response) + auth_request = await gen.__anext__() + while True: + auth_response = yield auth_request + try: + auth_request = await gen.asend(auth_response) + except StopAsyncIteration: + break except Exception: # pragma: no cover logger.exception("OAuth flow error") raise - # Retry with new tokens self._add_auth_header(request) yield request diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py index e05453dbd..eceb1d299 100644 --- a/src/mcp/client/auth/protocols/oauth2.py +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -120,10 +120,11 @@ def __init__( self._dpop_generator: DPoPProofGeneratorImpl | None = None async def authenticate(self, context: AuthContext) -> AuthCredentials: - """从 AuthContext 组装 OAuth 上下文,委托 OAuthClientProvider.run_authentication,返回 OAuthCredentials。""" - if context.http_client is None: - raise ValueError("OAuth2Protocol.authenticate requires context.http_client") - + """从 AuthContext 组装 OAuth 上下文,委托 OAuthClientProvider.run_authentication,返回 OAuthCredentials。 + + Note: Uses a fresh httpx client without auth for OAuth flow to avoid lock + deadlock when called from within MultiProtocolAuthProvider.async_auth_flow. + """ provider = OAuthClientProvider( server_url=context.server_url, client_metadata=self._client_metadata, @@ -138,13 +139,16 @@ async def authenticate(self, context: AuthContext) -> AuthCredentials: protocol_version = getattr( context.protocol_metadata, "protocol_version", None ) - await provider.run_authentication( - context.http_client, - resource_metadata_url=context.resource_metadata_url, - scope_from_www_auth=context.scope_from_www_auth, - protocol_version=protocol_version, - protected_resource_metadata=context.protected_resource_metadata, - ) + # Use a fresh client without auth for OAuth discovery/registration/token exchange + # to avoid lock deadlock when called from async_auth_flow + async with httpx.AsyncClient(follow_redirects=True) as oauth_client: + await provider.run_authentication( + oauth_client, + resource_metadata_url=context.resource_metadata_url, + scope_from_www_auth=context.scope_from_www_auth, + protocol_version=protocol_version, + protected_resource_metadata=context.protected_resource_metadata, + ) if not provider.context.current_tokens: raise RuntimeError("run_authentication completed but no tokens in provider") return _token_to_oauth_credentials(provider.context.current_tokens) diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py index bf05890d2..59592f09d 100644 --- a/src/mcp/server/auth/verifiers.py +++ b/src/mcp/server/auth/verifiers.py @@ -88,7 +88,8 @@ async def verify( if dpop_proof: try: http_uri = str(request.url) - http_method = request.method + # Use scope to get method for HTTPConnection compatibility + http_method = request.scope.get("method", "") await dpop_verifier.verify( dpop_proof, From 283a84e8c2a103e5e9da54eb0b0144e19cfa4970 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Mon, 2 Feb 2026 10:59:09 +0800 Subject: [PATCH 25/64] chore(tests): move integration scripts to client example dir. --- .../simple-auth-multiprotocol-client/run_dpop_test.sh | 7 ++++--- .../run_multiprotocol_test.sh | 7 ++++--- .../simple-auth-multiprotocol-client/run_oauth2_test.sh | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) rename scripts/run_phase4_dpop_integration_test.sh => examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh (97%) rename scripts/run_phase2_multiprotocol_integration_test.sh => examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh (89%) rename scripts/run_phase1_oauth2_integration_test.sh => examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh (87%) diff --git a/scripts/run_phase4_dpop_integration_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh similarity index 97% rename from scripts/run_phase4_dpop_integration_test.sh rename to examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh index 19c387da2..bf06071b7 100755 --- a/scripts/run_phase4_dpop_integration_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Phase 4 DPoP integration test: start simple-auth (AS) and simple-auth-multiprotocol (RS with DPoP), +# DPoP integration test: start simple-auth AS and simple-auth-multiprotocol RS with DPoP, # then run automated DPoP verification tests and optional OAuth+DPoP manual test. # -# Usage: from repo root, run: ./scripts/run_phase4_dpop_integration_test.sh +# This test is for testing DPoP + OAuth2 flow with multi-protocol support. +# Usage: in the repo root, run: ./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh # # Env variables: # MCP_RS_PORT - Resource Server port (default: 8002) @@ -17,7 +18,7 @@ set -e -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" MULTIPROTOCOL_SERVER="${REPO_ROOT}/examples/servers/simple-auth-multiprotocol" MULTIPROTOCOL_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-multiprotocol-client" diff --git a/scripts/run_phase2_multiprotocol_integration_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh similarity index 89% rename from scripts/run_phase2_multiprotocol_integration_test.sh rename to examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh index 860b21406..f7d271f20 100755 --- a/scripts/run_phase2_multiprotocol_integration_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash -# Phase 2 multi-protocol integration test: start simple-auth-multiprotocol RS (and optionally AS for OAuth), +# Multi-protocol integration test: start simple-auth-multiprotocol RS (and optionally AS for OAuth), # then run client with API Key, OAuth, or Mutual TLS (placeholder). -# Usage: from repo root, run: ./scripts/run_phase2_multiprotocol_integration_test.sh +# This test is for testing multi-protocol support with API Key, OAuth, or Mutual TLS. +# Usage: in the repo root, run: ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh # Env: MCP_PHASE2_PROTOCOL=api_key (default) | oauth | mutual_tls (client will show "not implemented" for mTLS). # For api_key/mutual_tls: simple-auth-multiprotocol-client; for oauth: simple-auth-client (complete OAuth in browser). # You must run at mcp> prompt: list, call get_time {}, quit. set -e -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" MULTIPROTOCOL_SERVER="${REPO_ROOT}/examples/servers/simple-auth-multiprotocol" MULTIPROTOCOL_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-multiprotocol-client" diff --git a/scripts/run_phase1_oauth2_integration_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh similarity index 87% rename from scripts/run_phase1_oauth2_integration_test.sh rename to examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh index d6d19657f..07695816f 100755 --- a/scripts/run_phase1_oauth2_integration_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Phase 1 OAuth2 integration test: start simple-auth (AS + RS) and run simple-auth-client. -# Usage: from repo root, run: ./scripts/run_phase1_oauth2_integration_test.sh +# OAuth2 integration test: start simple-auth (AS + RS) and run simple-auth-client. +# This test is for testing Oauth2 flow with multi-protocol support. +# Usage: in the repo root, run: ./examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh # You must complete OAuth in the browser and run list / call get_time / quit at the mcp> prompt. set -e -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" SIMPLE_AUTH_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-client" AS_PORT=9000 From 4589cada0e9dbed6389e924746a6569649740589 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Mon, 2 Feb 2026 11:00:56 +0800 Subject: [PATCH 26/64] fix(server): handle session termination race in streamable HTTP --- src/mcp/server/streamable_http.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index e9156f7ba..5636eec87 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -496,10 +496,18 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re ) await response(scope, receive, send) - # Process the message after sending the response + # Process the message after sending the response. + # Skip if session terminated (e.g., DELETE processed concurrently). + if self._terminated: + return metadata = ServerMessageMetadata(request_context=request) session_message = SessionMessage(message, metadata=metadata) - await writer.send(session_message) + try: + await writer.send(session_message) + except anyio.ClosedResourceError: + # Session terminated while processing; 202 already sent, do not send again + logger.debug("Writer closed during notification handling (session terminated)") + return return @@ -623,6 +631,12 @@ async def sse_writer(): await sse_stream_reader.aclose() await self._clean_up_memory_streams(request_id) + except anyio.ClosedResourceError as err: # pragma: no cover + # Session terminated (e.g., DELETE processed) while handling POST. + # Response may have already been sent (e.g., 202 for notifications). + # Do not attempt to send another response to avoid ASGI "after response already completed". + logger.debug("Writer closed during POST handling (session terminated): %s", err) + return except Exception as err: # pragma: no cover logger.exception("Error handling POST request") response = self._create_error_response( @@ -632,7 +646,10 @@ async def sse_writer(): ) await response(scope, receive, send) if writer: - await writer.send(Exception(err)) + try: + await writer.send(Exception(err)) + except anyio.ClosedResourceError: + logger.debug("Writer already closed, skipping exception propagation") return async def _handle_get_request(self, request: Request, send: Send) -> None: # pragma: no cover From e9338fc669c8b38fb6abda93a347d24a990afb08 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 4 Feb 2026 17:38:54 +0800 Subject: [PATCH 27/64] fix(auth): fallback to injected protocols on 401 Prefer only locally injected protocol instances when selecting the auth protocol, and ensure the final response corresponds to the original request (avoid leaking discovery responses). --- src/mcp/client/auth/multi_protocol.py | 87 ++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index 1f0cf1ea8..f934f8046 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -20,6 +20,7 @@ import json import logging +import math import time from collections.abc import AsyncGenerator from typing import Any, Protocol, cast @@ -32,7 +33,6 @@ from mcp.client.auth._oauth_401_flow import oauth_401_flow_generator from mcp.client.auth.oauth2 import OAuthClientProvider, TokenStorage as OAuth2TokenStorage from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol -from mcp.client.auth.registry import AuthProtocolRegistry from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.auth.utils import ( build_protected_resource_metadata_discovery_urls, @@ -55,6 +55,9 @@ logger = logging.getLogger(__name__) +# Protocol preferences: any protocol without an explicit preference should sort last. +UNSPECIFIED_PROTOCOL_PREFERENCE: float = math.inf + class TokenStorage(Protocol): """凭证存储协议(兼容 OAuthToken 与 AuthCredentials)。""" @@ -248,12 +251,16 @@ async def async_auth_flow( response = yield request if response.status_code == 401: + original_request = request + original_401_response = response async with self._lock: resource_metadata_url = extract_resource_metadata_from_www_auth(response) auth_protocols_header = extract_auth_protocols_from_www_auth(response) default_protocol = extract_default_protocol_from_www_auth(response) protocol_preferences = extract_protocol_preferences_from_www_auth(response) server_url = str(request.url) + attempted_any = False + last_auth_error: Exception | None = None # Step 1: PRM discovery (yield) prm: ProtectedResourceMetadata | None = None @@ -286,19 +293,51 @@ async def async_auth_flow( if not available: logger.debug("No available protocols from discovery or WWW-Authenticate") else: - selected_id = AuthProtocolRegistry.select_protocol( - available, default_protocol, protocol_preferences - ) - if selected_id: + # Select protocol candidates based on server hints, but only + # attempt protocols that are actually injected as instances. + candidates: list[str] = [] + seen: set[str] = set() + + def _push(pid: str | None) -> None: + if not pid: + return + if pid in seen: + return + seen.add(pid) + candidates.append(pid) + + # Default protocol first (server recommendation) + _push(default_protocol) + # Then order by preferences if provided + if protocol_preferences: + for pid in sorted( + available, + key=lambda p: protocol_preferences.get( + p, UNSPECIFIED_PROTOCOL_PREFERENCE + ), + ): + _push(pid) + # Then remaining in server-provided order + for pid in available: + _push(pid) + + for selected_id in candidates: protocol = self._get_protocol(selected_id) - if protocol: - protocol_metadata = None - if protocols_metadata: - for m in protocols_metadata: - if m.protocol_id == selected_id: - protocol_metadata = m - break - + if protocol is None: + logger.debug( + "Protocol %s not injected as instance; skipping", selected_id + ) + continue + attempted_any = True + + protocol_metadata = None + if protocols_metadata: + for m in protocols_metadata: + if m.protocol_id == selected_id: + protocol_metadata = m + break + + try: if selected_id == "oauth2": # OAuth: drive shared generator (single client, yield) oauth_protocol = protocol @@ -325,7 +364,7 @@ async def async_auth_flow( MCP_PROTOCOL_VERSION ) gen = oauth_401_flow_generator( - provider, request, response, initial_prm=prm + provider, original_request, original_401_response, initial_prm=prm ) auth_req = await gen.__anext__() while True: @@ -348,17 +387,35 @@ async def async_auth_flow( resource_metadata_url=resource_metadata_url, protected_resource_metadata=prm, scope_from_www_auth=extract_scope_from_www_auth( - response + original_401_response ), ) credentials = await protocol.authenticate(context) to_store = _credentials_to_storage(credentials) await self.storage.set_tokens(to_store) + # Stop after first successful protocol path that stores credentials + break + except Exception as e: + last_auth_error = e + logger.debug( + "Protocol %s authentication failed: %s", selected_id, e + ) + continue + credentials = await self._get_credentials() if credentials and self._is_credentials_valid(credentials): await self._ensure_dpop_initialized(credentials) self._prepare_request(request, credentials) response = yield request + else: + if attempted_any and last_auth_error is not None: + # If we did attempt an injected protocol and it failed, surface the error + # instead of returning a potentially confusing 401. + raise last_auth_error + # Ensure we do not leak discovery responses as the final response: + # retry the original request once without new credentials so the + # caller receives a response corresponding to the original request. + response = yield original_request elif response.status_code == 403: await self._handle_403_response(response, request) From 571edfea150c31a059ef88e2dd81d9a9d290ac55 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 4 Feb 2026 17:39:00 +0800 Subject: [PATCH 28/64] test(auth): cover protocol fallback and discovery leakage Add regression tests ensuring 401 handling falls back when the server default protocol isn't injected, and that discovery responses are never returned as the business request response. --- tests/client/test_multi_protocol_provider.py | 220 +++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/tests/client/test_multi_protocol_provider.py b/tests/client/test_multi_protocol_provider.py index 08c85259f..2206138c4 100644 --- a/tests/client/test_multi_protocol_provider.py +++ b/tests/client/test_multi_protocol_provider.py @@ -5,6 +5,7 @@ from mcp.client.auth.multi_protocol import ( MultiProtocolAuthProvider, + OAuthTokenStorageAdapter, TokenStorage, _credentials_to_storage, _oauth_token_to_credentials, @@ -55,6 +56,32 @@ async def discover_metadata( return None +class _MockApiKeyProtocol: + protocol_id = "api_key" + protocol_version = "1.0" + + def __init__(self, api_key: str) -> None: + self._api_key = api_key + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + return APIKeyCredentials(protocol_id="api_key", api_key=self._api_key) + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + assert isinstance(credentials, APIKeyCredentials) + request.headers["X-API-Key"] = credentials.api_key + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return isinstance(credentials, APIKeyCredentials) and bool(credentials.api_key) + + async def discover_metadata( + self, + metadata_url: str | None = None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + @pytest.fixture def mock_storage() -> _MockStorage: return _MockStorage() @@ -197,3 +224,196 @@ def test_prepare_request_no_op_when_protocol_missing( creds = AuthCredentials(protocol_id="other") provider._prepare_request(request, creds) assert _MockProtocol._prepare_called is False + + +@pytest.mark.anyio +async def test_401_flow_falls_back_when_default_protocol_not_injected() -> None: + """When server suggests default oauth2 but only api_key instance is injected, fallback to api_key and retry.""" + requests: list[httpx.Request] = [] + api_key = "demo-api-key-12345" + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = request.url.path + url = str(request.url) + + if request.method == "GET" and "oauth-protected-resource" in path: + prm = { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/"], + "mcp_auth_protocols": [ + {"protocol_id": "oauth2", "protocol_version": "2.0", "metadata_url": "https://as.example/.well-known/oauth-authorization-server"}, + {"protocol_id": "api_key", "protocol_version": "1.0"}, + {"protocol_id": "mutual_tls", "protocol_version": "1.0"}, + ], + } + return httpx.Response(200, json=prm) + + if request.method == "GET" and path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(404, text="not found") + + if request.method == "POST" and path == "/mcp": + if request.headers.get("x-api-key") == api_key: + return httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "rs", "version": "1.0"}}}, + ) + # 401 with multi-protocol hints + www = ( + 'Bearer error="invalid_token", ' + 'resource_metadata="https://rs.example/.well-known/oauth-protected-resource/mcp", ' + 'auth_protocols="oauth2 api_key mutual_tls", ' + 'default_protocol="oauth2"' + ) + return httpx.Response(401, headers={"www-authenticate": www}, text="unauthorized") + + return httpx.Response(500, text=f"unexpected {request.method} {url}") + + transport = httpx.MockTransport(handler) + storage = _MockStorage() + proto = _MockApiKeyProtocol(api_key=api_key) + + async with httpx.AsyncClient(transport=transport) as client: + provider = MultiProtocolAuthProvider( + server_url="https://rs.example", + storage=storage, + protocols=[proto], + http_client=client, + ) + client.auth = provider + r = await client.post("https://rs.example/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "1.0"}}}) + + assert r.status_code == 200 + # Must have retried POST /mcp with X-API-Key + post_mcp = [req for req in requests if req.method == "POST" and req.url.path == "/mcp"] + assert len(post_mcp) >= 2 + assert any(req.headers.get("x-api-key") == api_key for req in post_mcp) + + +@pytest.mark.anyio +async def test_401_flow_does_not_leak_discovery_response_when_no_protocols_injected() -> None: + """If no protocol instance is available, final response should correspond to original request (401), not discovery 404.""" + seen: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + if request.method == "GET" and "oauth-protected-resource" in request.url.path: + prm = { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/"], + "mcp_auth_protocols": [ + {"protocol_id": "oauth2", "protocol_version": "2.0", "metadata_url": "https://as.example/.well-known/oauth-authorization-server"}, + {"protocol_id": "api_key", "protocol_version": "1.0"}, + ], + } + return httpx.Response(200, json=prm) + if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(404, text="not found") + if request.method == "POST" and request.url.path == "/mcp": + www = ( + 'Bearer error="invalid_token", ' + 'resource_metadata="https://rs.example/.well-known/oauth-protected-resource/mcp", ' + 'auth_protocols="oauth2 api_key", ' + 'default_protocol="oauth2"' + ) + return httpx.Response(401, headers={"www-authenticate": www}, text="unauthorized") + return httpx.Response(500) + + transport = httpx.MockTransport(handler) + storage = _MockStorage() + + async with httpx.AsyncClient(transport=transport) as client: + provider = MultiProtocolAuthProvider( + server_url="https://rs.example", + storage=storage, + protocols=[], + http_client=client, + ) + client.auth = provider + r = await client.post("https://rs.example/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "1.0"}}}) + + assert r.status_code == 401 + # We should have attempted discovery, but final response must not be the discovery 404. + assert ("GET", "/mcp/.well-known/authorization_servers") in seen + + +class _OAuthTokenOnlyMockStorage: + """Minimal storage that only supports OAuthToken (dual contract: oauth2 side).""" + + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + +@pytest.mark.anyio +async def test_oauth_token_storage_adapter_get_tokens_returns_credentials_when_wrapped_has_token() -> None: + """OAuthTokenStorageAdapter.get_tokens converts OAuthToken to OAuthCredentials.""" + raw = OAuthToken( + access_token="at", + token_type="Bearer", + expires_in=3600, + scope="read", + refresh_token="rt", + ) + wrapped = _OAuthTokenOnlyMockStorage() + wrapped._tokens = raw + adapter = OAuthTokenStorageAdapter(wrapped) + + result = await adapter.get_tokens() + + assert result is not None + assert isinstance(result, OAuthCredentials) + assert result.protocol_id == "oauth2" + assert result.access_token == "at" + assert result.refresh_token == "rt" + + +@pytest.mark.anyio +async def test_oauth_token_storage_adapter_set_tokens_stores_oauth_token_when_given_credentials() -> None: + """OAuthTokenStorageAdapter.set_tokens converts OAuthCredentials to OAuthToken and stores.""" + wrapped = _OAuthTokenOnlyMockStorage() + adapter = OAuthTokenStorageAdapter(wrapped) + creds = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + token_type="Bearer", + refresh_token="rt", + scope="read", + expires_at=None, + ) + + await adapter.set_tokens(creds) + + assert wrapped._tokens is not None + assert wrapped._tokens.access_token == "at" + assert wrapped._tokens.refresh_token == "rt" + + +@pytest.mark.anyio +async def test_get_credentials_returns_oauth_credentials_when_storage_returns_oauth_token() -> None: + """MultiProtocolAuthProvider._get_credentials converts OAuthToken from storage to OAuthCredentials (dual contract).""" + raw = OAuthToken( + access_token="stored_at", + token_type="Bearer", + expires_in=3600, + scope="read", + ) + storage = _MockStorage() + storage._tokens = raw + provider = MultiProtocolAuthProvider( + server_url="https://example.com", + storage=storage, + protocols=[], + ) + provider._initialize() + + result = await provider._get_credentials() + + assert result is not None + assert isinstance(result, OAuthCredentials) + assert result.access_token == "stored_at" From 5d38e4b1ee51eeb61871387e8cec1e442e70aadf Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 4 Feb 2026 17:39:06 +0800 Subject: [PATCH 29/64] chore(examples): harden multiprotocol client/script flows Make api_key and mutual_tls runs non-interactive with clear PASS/FAIL, add oauth_dpop mode wiring, and allow forcing mutual_tls protocol injection for placeholder coverage. --- .../main.py | 14 ++-- .../run_dpop_test.sh | 16 ++++- .../run_multiprotocol_test.sh | 67 ++++++++++++++----- 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py index 5adb2e8b7..156d1de96 100644 --- a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -233,10 +233,16 @@ async def redirect_handler(url: str) -> None: protocols.append(oauth_protocol) print(f"OAuth protocol enabled (DPoP: {self.dpop_enabled})") - # Always add API Key and mTLS as fallback - api_key = os.getenv("MCP_API_KEY", "demo-api-key-12345") - protocols.append(ApiKeyProtocol(api_key=api_key)) - protocols.append(MutualTlsPlaceholderProtocol()) + # Add non-OAuth protocols. Allow forcing protocol injection for integration tests. + forced = os.getenv("MCP_AUTH_PROTOCOL", os.getenv("MCP_PHASE2_PROTOCOL", "")).strip().lower() + if forced in ("mutual_tls", "mtls"): + # Force mTLS placeholder to be selectable (do not inject API key fallback). + protocols.append(MutualTlsPlaceholderProtocol()) + else: + # Default: API key (from env) plus mTLS placeholder as fallback. + api_key = os.getenv("MCP_API_KEY", "demo-api-key-12345") + protocols.append(ApiKeyProtocol(api_key=api_key)) + protocols.append(MutualTlsPlaceholderProtocol()) try: # Create http_client first, then pass it to auth provider diff --git a/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh index bf06071b7..f2deca9d1 100755 --- a/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh @@ -115,8 +115,8 @@ echo "Running Automated DPoP Tests" echo "============================================================" echo "" -# Test B2: API Key Authentication -echo "[Test B2] API Key Authentication (DPoP should not affect)" +# Test B2: API Key Authentication (curl) +echo "[Test B2] API Key Authentication via curl (DPoP should not affect)" STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ @@ -124,6 +124,18 @@ STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"dpop-test","version":"1.0"}}}') run_test "API Key auth works with DPoP enabled" "200" "$STATUS" +# Test B3: API Key Authentication via MultiProtocolAuth client +echo "[Test B3] API Key Authentication via MultiProtocolAuth client" +cd "$MULTIPROTOCOL_CLIENT" +if printf "list\ncall get_time {}\nquit\n" | MCP_SERVER_URL="$MCP_ENDPOINT" MCP_API_KEY="$API_KEY" MCP_AUTH_PROTOCOL="api_key" uv run mcp-simple-auth-multiprotocol-client >/dev/null 2>&1; then + echo " PASS: simple-auth-multiprotocol-client (API Key via MultiProtocolAuth)" + PASSED=$((PASSED + 1)) +else + echo " FAIL: simple-auth-multiprotocol-client (API Key via MultiProtocolAuth)" + FAILED=$((FAILED + 1)) +fi +cd "$REPO_ROOT" + # Test: No Authentication echo "[Test] No Authentication (expect 401)" STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_ENDPOINT" \ diff --git a/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh index f7d271f20..cd6b56898 100755 --- a/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Multi-protocol integration test: start simple-auth-multiprotocol RS (and optionally AS for OAuth), -# then run client with API Key, OAuth, or Mutual TLS (placeholder). -# This test is for testing multi-protocol support with API Key, OAuth, or Mutual TLS. +# Multi-protocol integration test (MultiProtocolAuthProvider): +# start simple-auth-multiprotocol RS (and optionally AS for OAuth), +# then run simple-auth-multiprotocol-client with API Key, OAuth, OAuth+DPoP, or Mutual TLS (placeholder). # Usage: in the repo root, run: ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh -# Env: MCP_PHASE2_PROTOCOL=api_key (default) | oauth | mutual_tls (client will show "not implemented" for mTLS). -# For api_key/mutual_tls: simple-auth-multiprotocol-client; for oauth: simple-auth-client (complete OAuth in browser). -# You must run at mcp> prompt: list, call get_time {}, quit. +# Env: MCP_AUTH_PROTOCOL=api_key (default) | oauth | oauth_dpop | mutual_tls +# For api_key/mutual_tls: script runs non-interactive commands (list/call/quit) and asserts PASS/FAIL. +# For oauth/oauth_dpop: complete OAuth in browser, then run: list, call get_time {}, quit. +# Optional: MCP_SKIP_OAUTH=1 to skip oauth/oauth_dpop manual cases. set -e @@ -13,14 +14,15 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" SIMPLE_AUTH_SERVER="${REPO_ROOT}/examples/servers/simple-auth" MULTIPROTOCOL_SERVER="${REPO_ROOT}/examples/servers/simple-auth-multiprotocol" MULTIPROTOCOL_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-multiprotocol-client" -SIMPLE_AUTH_CLIENT="${REPO_ROOT}/examples/clients/simple-auth-client" RS_PORT="${MCP_RS_PORT:-8002}" AS_PORT="${MCP_AS_PORT:-9000}" -PROTOCOL="${MCP_PHASE2_PROTOCOL:-api_key}" +PROTOCOL="${MCP_AUTH_PROTOCOL:-api_key}" +SKIP_OAUTH="${MCP_SKIP_OAUTH:-0}" cd "$REPO_ROOT" echo "Repo root: $REPO_ROOT" echo "Protocol: $PROTOCOL" +echo "Skip OAuth: $SKIP_OAUTH" uv sync --quiet 2>/dev/null || true @@ -49,7 +51,7 @@ cleanup() { trap cleanup EXIT # Start Authorization Server only for OAuth -if [ "$PROTOCOL" = "oauth" ]; then +if [ "$PROTOCOL" = "oauth" ] || [ "$PROTOCOL" = "oauth_dpop" ]; then cd "$SIMPLE_AUTH_SERVER" uv run mcp-simple-auth-as --port="$AS_PORT" & AS_PID=$! @@ -61,6 +63,8 @@ fi cd "$MULTIPROTOCOL_SERVER" if [ "$PROTOCOL" = "oauth" ]; then uv run mcp-simple-auth-multiprotocol-rs --port="$RS_PORT" --auth-server="http://localhost:${AS_PORT}" --api-keys="demo-api-key-12345" & +elif [ "$PROTOCOL" = "oauth_dpop" ]; then + uv run mcp-simple-auth-multiprotocol-rs --port="$RS_PORT" --auth-server="http://localhost:${AS_PORT}" --api-keys="demo-api-key-12345" --dpop-enabled & else uv run mcp-simple-auth-multiprotocol-rs --port="$RS_PORT" --api-keys="demo-api-key-12345" & fi @@ -76,20 +80,47 @@ echo "" echo "" # Run client by protocol -if [ "$PROTOCOL" = "oauth" ]; then - echo "Starting simple-auth-client (OAuth). Complete OAuth in the browser, then run: list, call get_time {}, quit" +if [ "$PROTOCOL" = "oauth" ] || [ "$PROTOCOL" = "oauth_dpop" ]; then + if [ "$SKIP_OAUTH" = "1" ]; then + echo "Skipping OAuth manual test (MCP_SKIP_OAUTH=1)" + exit 0 + fi + echo "Starting simple-auth-multiprotocol-client (OAuth). Complete OAuth in the browser, then run: list, call get_time {}, quit" echo "" - cd "$SIMPLE_AUTH_CLIENT" - MCP_SERVER_PORT="$RS_PORT" MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client + cd "$MULTIPROTOCOL_CLIENT" + MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" \ + MCP_USE_OAUTH=1 \ + MCP_DPOP_ENABLED=$([ "$PROTOCOL" = "oauth_dpop" ] && echo 1 || echo 0) \ + MCP_AUTH_PROTOCOL="$PROTOCOL" \ + uv run mcp-simple-auth-multiprotocol-client elif [ "$PROTOCOL" = "mutual_tls" ]; then - echo "Starting simple-auth-multiprotocol-client (mTLS placeholder). At mcp> run: list, call get_time {}, quit" + echo "Running mTLS placeholder selection (expect not implemented)" echo "" cd "$MULTIPROTOCOL_CLIENT" - unset MCP_API_KEY - MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" uv run mcp-simple-auth-multiprotocol-client + set +e + OUT=$(MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" MCP_AUTH_PROTOCOL="mutual_tls" uv run mcp-simple-auth-multiprotocol-client 2>&1) + CODE=$? + set -e + echo "$OUT" | head -60 + if echo "$OUT" | grep -q "Mutual TLS not implemented"; then + echo "PASS: mutual_tls placeholder reported not implemented" + exit 0 + fi + echo "FAIL: mutual_tls placeholder did not report expected error (exit=$CODE)" + exit 1 else - echo "Starting simple-auth-multiprotocol-client (API Key). At mcp> run: list, call get_time {}, quit" + echo "Running API Key flow (non-interactive): list, call get_time {}, quit" echo "" cd "$MULTIPROTOCOL_CLIENT" - MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" MCP_API_KEY="demo-api-key-12345" uv run mcp-simple-auth-multiprotocol-client + set +e + OUT=$(printf "list\ncall get_time {}\nquit\n" | MCP_SERVER_URL="http://localhost:${RS_PORT}/mcp" MCP_API_KEY="demo-api-key-12345" MCP_AUTH_PROTOCOL="api_key" uv run mcp-simple-auth-multiprotocol-client 2>&1) + CODE=$? + set -e + echo "$OUT" | head -80 + if [ "$CODE" -eq 0 ] && echo "$OUT" | grep -q "Session initialized" && ! echo "$OUT" | grep -q "Session terminated"; then + echo "PASS: api_key flow succeeded" + exit 0 + fi + echo "FAIL: api_key flow failed (exit=$CODE)" + exit 1 fi From fbf9ad97987d677b5acafde1cc05fb8134f6a765 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 30 Jan 2026 00:18:02 +0800 Subject: [PATCH 30/64] docs(auth): document TokenStorage dual contract, add OAuthTokenStorageAdapter and unit tests --- src/mcp/client/auth/multi_protocol.py | 52 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index f934f8046..a5052cfb1 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -12,8 +12,9 @@ - **转换约定**:MultiProtocolAuthProvider 在调用方做转换,不扩展协议方法: - 取回时:_get_credentials() 调用 storage.get_tokens(),若得到 OAuthToken 则经 _oauth_token_to_credentials 转为 OAuthCredentials。 - - 写入时:401 流程得到 AuthCredentials 后经 _credentials_to_storage 转为 - OAuthToken(仅 OAuthCredentials 转 OAuthToken,其他凭证原样),再调用 storage.set_tokens(to_store)。 + - 写入时:_discover_and_authenticate 得到 AuthCredentials 后经 _credentials_to_storage + 转为 OAuthToken(仅 OAuthCredentials 转 OAuthToken,其他凭证原样),再调用 + storage.set_tokens(to_store)。 - 因此仅实现 get_tokens/set_tokens(OAuthToken) 的旧存储可直接用于 MultiProtocolAuthProvider, 无需改存储实现。可选使用 OAuthTokenStorageAdapter 将此类存储包装为满足 multi_protocol 契约。 """ @@ -60,7 +61,14 @@ class TokenStorage(Protocol): - """凭证存储协议(兼容 OAuthToken 与 AuthCredentials)。""" + """ + 凭证存储协议(multi_protocol 契约)。 + + 本协议接受 get_tokens() -> AuthCredentials | OAuthToken | None 与 + set_tokens(AuthCredentials | OAuthToken)。仅支持 OAuthToken 的旧存储亦可使用: + MultiProtocolAuthProvider 在 _get_credentials/_discover_and_authenticate 内做 + OAuthToken <-> OAuthCredentials 转换;或使用 OAuthTokenStorageAdapter 包装。 + """ async def get_tokens(self) -> AuthCredentials | OAuthToken | None: """获取已存储的凭证。""" @@ -109,6 +117,44 @@ def _credentials_to_storage(credentials: AuthCredentials) -> AuthCredentials | O return credentials +class _OAuthTokenOnlyStorage(Protocol): + """仅支持 OAuthToken 的存储契约(供 OAuthTokenStorageAdapter 包装)。""" + + async def get_tokens(self) -> OAuthToken | None: + ... + + async def set_tokens(self, tokens: OAuthToken) -> None: + ... + + +class OAuthTokenStorageAdapter: + """ + 将仅支持 OAuthToken 的 storage 包装为满足 multi_protocol TokenStorage。 + + 取回时把 OAuthToken 转为 OAuthCredentials;写入时把 OAuthCredentials 转为 OAuthToken + 再调用底层 set_tokens。仅 OAuth 凭证会写入底层存储,非 OAuth 凭证(如 APIKeyCredentials) + 不写入。 + """ + + def __init__(self, wrapped: _OAuthTokenOnlyStorage) -> None: + self._wrapped = wrapped + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + raw = await self._wrapped.get_tokens() + if raw is None: + return None + return _oauth_token_to_credentials(raw) + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + to_store = ( + _credentials_to_storage(tokens) + if isinstance(tokens, AuthCredentials) + else tokens + ) + if isinstance(to_store, OAuthToken): + await self._wrapped.set_tokens(to_store) + + class MultiProtocolAuthProvider(httpx.Auth): """ 多协议认证提供者。 From 5129578c4c589fd9fd0b459af8d006ff70ddc42c Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 4 Feb 2026 17:57:26 +0800 Subject: [PATCH 31/64] chore(repo): ignore local plans and cursorrules Ignore /plans/ and /.cursorrules to keep local scratch docs and Cursor config out of version control. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index de1699559..ec70997b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ .DS_Store scratch/ +# Local plans and scratch docs (never commit) +/plans/ + +# Cursor AI assistant rules (local only) +/.cursorrules + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 53b11f97240f0bae45716bd3e1370cfef39be365 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 5 Feb 2026 22:36:32 +0800 Subject: [PATCH 32/64] feat(auth): OAuth2 client_credentials grant and fixed_client_info - OAuthClientProvider: add fixed_client_info, _exchange_token_client_credentials - OAuth2Protocol: accept fixed_client_info for M2M flows - MultiProtocolAuthProvider: pass fixed_client_info into OAuthClientProvider - _oauth_401_flow: require client_info for client_credentials, skip dynamic registration - Server token handler: ClientCredentialsRequest, exchange_client_credentials - Server routes: advertise client_credentials in grant_types_supported --- src/mcp/client/auth/_oauth_401_flow.py | 5 ++++ src/mcp/client/auth/multi_protocol.py | 21 ++++--------- src/mcp/client/auth/oauth2.py | 40 +++++++++++++++++++++++-- src/mcp/client/auth/protocols/oauth2.py | 4 +++ src/mcp/server/auth/handlers/token.py | 37 ++++++++++++++++++++++- 5 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/mcp/client/auth/_oauth_401_flow.py b/src/mcp/client/auth/_oauth_401_flow.py index 8187a3bd2..347142cd9 100644 --- a/src/mcp/client/auth/_oauth_401_flow.py +++ b/src/mcp/client/auth/_oauth_401_flow.py @@ -11,6 +11,7 @@ import httpx +from mcp.client.auth.exceptions import OAuthFlowError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -117,6 +118,10 @@ async def oauth_401_flow_generator( ) # Step 4: Register client or use URL-based client ID (CIMD) + # For client_credentials, a fixed client_id/client_secret must be provided; do not attempt DCR/CIMD. + if "client_credentials" in (ctx.client_metadata.grant_types or []) and not ctx.client_info: + raise OAuthFlowError("Missing client_info for client_credentials flow") + if not ctx.client_info: if should_use_client_metadata_url(ctx.oauth_metadata, ctx.client_metadata_url): logger.debug("Using URL-based client ID (CIMD): %s", ctx.client_metadata_url) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index a5052cfb1..a28c914dc 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -393,22 +393,13 @@ def _push(pid: str | None) -> None: oauth_protocol, "_client_metadata" ), storage=cast(OAuth2TokenStorage, self.storage), - redirect_handler=getattr( - oauth_protocol, "_redirect_handler", None - ), - callback_handler=getattr( - oauth_protocol, "_callback_handler", None - ), - timeout=getattr( - oauth_protocol, "_timeout", self.timeout - ), - client_metadata_url=getattr( - oauth_protocol, "_client_metadata_url", None - ), - ) - provider.context.protocol_version = request.headers.get( - MCP_PROTOCOL_VERSION + redirect_handler=getattr(oauth_protocol, "_redirect_handler", None), + callback_handler=getattr(oauth_protocol, "_callback_handler", None), + timeout=getattr(oauth_protocol, "_timeout", self.timeout), + client_metadata_url=getattr(oauth_protocol, "_client_metadata_url", None), + fixed_client_info=getattr(oauth_protocol, "_fixed_client_info", None), ) + provider.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) gen = oauth_401_flow_generator( provider, original_request, original_401_response, initial_prm=prm ) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 7e4ddb4c6..7a00eb55a 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -228,6 +228,7 @@ def __init__( callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, client_metadata_url: str | None = None, + fixed_client_info: OAuthClientInformationFull | None = None, ): """Initialize OAuth2 authentication. @@ -262,6 +263,11 @@ def __init__( timeout=timeout, client_metadata_url=client_metadata_url, ) + self._fixed_client_info = fixed_client_info + if fixed_client_info is not None: + # In multi-protocol OAuth flow, we may drive oauth_401_flow_generator directly + # without calling _initialize(); ensure client_info is available upfront. + self.context.client_info = fixed_client_info self._initialized = False async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: @@ -297,6 +303,10 @@ async def _handle_protected_resource_response(self, response: httpx.Response) -> async def _perform_authorization(self) -> httpx.Request: """Perform the authorization flow.""" + grant_types = set(self.context.client_metadata.grant_types or []) + if "client_credentials" in grant_types: + token_request = await self._exchange_token_client_credentials() + return token_request auth_code, code_verifier = await self._perform_authorization_code_grant() token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) return token_request @@ -362,6 +372,31 @@ def _get_token_endpoint(self) -> str: token_url = urljoin(auth_base_url, "/token") return token_url + async def _exchange_token_client_credentials(self) -> httpx.Request: + """Build token exchange request for client_credentials flow.""" + if not self.context.client_info: + raise OAuthFlowError("Missing client info for client_credentials flow") + + token_url = self._get_token_endpoint() + token_data: dict[str, str] = { + "grant_type": "client_credentials", + } + + # Some servers require explicit client_id in the form body (especially for client_secret_post). + if self.context.client_info.client_id: + token_data["client_id"] = self.context.client_info.client_id + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() # RFC 8707 + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + token_data, headers = self.context.prepare_token_auth(token_data, headers) + return httpx.Request("POST", token_url, data=token_data, headers=headers) + async def _exchange_token_authorization_code( self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {} ) -> httpx.Request: @@ -543,15 +578,14 @@ async def run_authentication( self.context.client_info = client_information await self.context.storage.set_client_info(client_information) - auth_code, code_verifier = await self._perform_authorization_code_grant() - token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + token_request = await self._perform_authorization() token_response = await http_client.send(token_request) await self._handle_token_response(token_response) async def _initialize(self) -> None: # pragma: no cover """Load stored tokens and client info.""" self.context.current_tokens = await self.context.storage.get_tokens() - self.context.client_info = await self.context.storage.get_client_info() + self.context.client_info = self._fixed_client_info or await self.context.storage.get_client_info() self._initialized = True def _add_auth_header(self, request: httpx.Request) -> None: diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py index eceb1d299..aef61bc46 100644 --- a/src/mcp/client/auth/protocols/oauth2.py +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -31,6 +31,7 @@ from mcp.shared.auth import ( AuthCredentials, AuthProtocolMetadata, + OAuthClientInformationFull, OAuthClientMetadata, OAuthCredentials, OAuthMetadata, @@ -104,6 +105,7 @@ def __init__( callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, client_metadata_url: str | None = None, + fixed_client_info: OAuthClientInformationFull | None = None, dpop_enabled: bool = False, dpop_algorithm: DPoPAlgorithm = "ES256", dpop_rsa_key_size: int = RSA_KEY_SIZE_DEFAULT, @@ -113,6 +115,7 @@ def __init__( self._callback_handler = callback_handler self._timeout = timeout self._client_metadata_url = client_metadata_url + self._fixed_client_info = fixed_client_info self._dpop_enabled = dpop_enabled self._dpop_algorithm: DPoPAlgorithm = dpop_algorithm self._dpop_rsa_key_size = dpop_rsa_key_size @@ -133,6 +136,7 @@ async def authenticate(self, context: AuthContext) -> AuthCredentials: callback_handler=self._callback_handler, timeout=self._timeout, client_metadata_url=self._client_metadata_url, + fixed_client_info=self._fixed_client_info, ) protocol_version: str | None = None if context.protocol_metadata is not None: diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 534a478a9..b089da690 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -40,7 +40,20 @@ class RefreshTokenRequest(BaseModel): resource: str | None = Field(None, description="Resource indicator for the token") -TokenRequest = Annotated[AuthorizationCodeRequest | RefreshTokenRequest, Field(discriminator="grant_type")] +class ClientCredentialsRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2 + grant_type: Literal["client_credentials"] + scope: str | None = Field(None, description="Optional scope parameter") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + +TokenRequest = Annotated[ + AuthorizationCodeRequest | RefreshTokenRequest | ClientCredentialsRequest, + Field(discriminator="grant_type"), +] token_request_adapter = TypeAdapter[TokenRequest](TokenRequest) @@ -216,4 +229,26 @@ async def handle(self, request: Request): except TokenError as e: return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + case ClientCredentialsRequest(): + # Exchange client credentials for access token + scope_str = token_request.scope or getattr(client_info, "scope", None) or "" + scopes = scope_str.split(" ") if scope_str else [] + exchange = getattr(self.provider, "exchange_client_credentials", None) + if exchange is None: + return self.response( + TokenErrorResponse( + error="unsupported_grant_type", + error_description="client_credentials is not supported by this authorization server", + ) + ) + try: + tokens = await exchange(client_info, scopes=scopes, resource=token_request.resource) + except TokenError as e: + return self.response( + TokenErrorResponse( + error=e.error, + error_description=e.error_description, + ) + ) + return self.response(tokens) From 1dda3cd50c759e92a870a61da8895bc50accd991 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 5 Feb 2026 22:36:43 +0800 Subject: [PATCH 33/64] feat(examples): client_credentials in simple-auth, multiprotocol discovery variants - simple-auth: exchange_client_credentials, demo client_credentials client - simple-auth-multiprotocol: add prm_only, path_only, root_only, oauth_fallback server variants and CLI entry points for discovery E2E testing --- .../__init__.py | 2 + .../__main__.py | 8 + .../multiprotocol.py | 103 +++++++ .../server.py | 250 +++++++++++++++++ .../token_verifier.py | 81 ++++++ .../__init__.py | 2 + .../__main__.py | 8 + .../multiprotocol.py | 103 +++++++ .../server.py | 263 ++++++++++++++++++ .../token_verifier.py | 81 ++++++ .../__init__.py | 2 + .../__main__.py | 8 + .../multiprotocol.py | 108 +++++++ .../server.py | 245 ++++++++++++++++ .../token_verifier.py | 81 ++++++ .../__init__.py | 2 + .../__main__.py | 8 + .../multiprotocol.py | 103 +++++++ .../server.py | 258 +++++++++++++++++ .../token_verifier.py | 81 ++++++ .../simple-auth-multiprotocol/pyproject.toml | 12 +- .../mcp_simple_auth/simple_auth_provider.py | 46 +++ 22 files changed, 1854 insertions(+), 1 deletion(-) create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py new file mode 100644 index 000000000..9873aa3a7 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py @@ -0,0 +1,2 @@ +"""MCP Resource Server (multiprotocol, OAuth-fallback discovery variant).""" + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py new file mode 100644 index 000000000..76d5ac5eb --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for multi-protocol MCP Resource Server (OAuth-fallback discovery).""" + +import sys + +from mcp_simple_auth_multiprotocol_oauth_fallback.server import main + +sys.exit(main()) # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py new file mode 100644 index 000000000..32416fc2f --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py @@ -0,0 +1,103 @@ +"""Multi-protocol auth adapter for OAuth-fallback discovery variant.""" + +import logging +import time +from typing import Any, cast + +from starlette.authentication import AuthCredentials, AuthenticationBackend +from starlette.requests import HTTPConnection, Request + +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import ( + APIKeyVerifier, + CredentialVerifier, + MultiProtocolAuthBackend, + OAuthTokenVerifier, +) + +logger = logging.getLogger(__name__) + + +class MutualTLSVerifier: + """Placeholder verifier for Mutual TLS.""" + + async def verify( + self, + request: Any, + dpop_verifier: Any = None, + ) -> AccessToken | None: + return None + + +def build_multiprotocol_backend( + oauth_token_verifier: Any, + api_key_valid_keys: set[str], + api_key_scopes: list[str] | None = None, + dpop_enabled: bool = False, +) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) + api_key_verifier = APIKeyVerifier( + valid_keys=api_key_valid_keys, + scopes=api_key_scopes or [], + ) + mtls_verifier: CredentialVerifier = MutualTLSVerifier() + backend = MultiProtocolAuthBackend( + verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] + ) + + dpop_verifier: DPoPProofVerifier | None = None + if dpop_enabled: + dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + return backend, dpop_verifier + + +class MultiProtocolAuthBackendAdapter(AuthenticationBackend): + """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" + + def __init__( + self, + backend: MultiProtocolAuthBackend, + dpop_verifier: DPoPProofVerifier | None = None, + ) -> None: + self._backend = backend + self._dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: + request = cast(Request, conn) + + dpop_header = request.headers.get("dpop") + if self._dpop_verifier is not None: + if dpop_header: + logger.info("DPoP proof present, verification enabled") + else: + logger.debug("DPoP verification enabled but no DPoP header in request") + elif dpop_header: + logger.debug("DPoP header present but verification not enabled (ignoring)") + + result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) + + if result is None: + if dpop_header and self._dpop_verifier is not None: + logger.warning("Authentication failed (DPoP proof may be invalid)") + else: + logger.debug("Authentication failed (no valid credentials)") + return None + + if result.expires_at is not None and result.expires_at < int(time.time()): + logger.warning("Token expired for client_id=%s", result.client_id) + return None + + if dpop_header and self._dpop_verifier is not None: + logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) + else: + logger.info("Authentication successful (client_id=%s)", result.client_id) + + return ( + AuthCredentials(result.scopes or []), + AuthenticatedUser(result), + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py new file mode 100644 index 000000000..ba5d64b8c --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py @@ -0,0 +1,250 @@ +""" +MCP Resource Server with multi-protocol auth (OAuth-fallback discovery variant). + +This variant: +- PRM does NOT include mcp_auth_protocols (only authorization_servers) +- Does NOT expose any unified discovery endpoints +- Forces clients to use OAuth fallback from PRM.authorization_servers +""" + +import contextlib +import datetime +import logging +from typing import Any, Literal + +import click +import uvicorn +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Route +from starlette.types import ASGIApp + +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_protected_resource_routes, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.shared.auth import AuthProtocolMetadata + +from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the multi-protocol MCP Resource Server (OAuth-fallback discovery).""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + host: str = "localhost" + port: int = 8002 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + mcp_scope: str = "user" + oauth_strict: bool = False + api_key_valid_keys: str = "demo-api-key-12345" + default_protocol: str = "oauth2" + protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + dpop_enabled: bool = False + + +def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: + """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" + auth_base = str(settings.auth_server_url).rstrip("/") + oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") + return [ + AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=oauth_metadata_url, + scopes_supported=[settings.mcp_scope], + ), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), + ] + + +def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: + """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" + out: dict[str, int] = {} + for part in prefs_str.split(","): + s = part.strip() + if ":" in s: + proto, prio = s.split(":", 1) + try: + out[proto.strip()] = int(prio.strip()) + except ValueError: + pass + return out + + +def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: + """Create Starlette app with MultiProtocolAuthBackend and PRM-only (no mcp_auth_protocols, no unified discovery).""" + oauth_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, + ) + api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} + backend, dpop_verifier = build_multiprotocol_backend( + oauth_verifier, + api_key_keys, + api_key_scopes=[settings.mcp_scope], + dpop_enabled=settings.dpop_enabled, + ) + adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) + + fastmcp = FastMCP( + name="MCP Resource Server (multiprotocol, OAuth-fallback discovery)", + instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (OAuth-fallback discovery)", + host=settings.host, + port=settings.port, + auth=None, + ) + + @fastmcp.tool() + async def get_time() -> dict[str, Any]: + """Return current server time (requires auth).""" + now = datetime.datetime.now() + return { + "current_time": now.isoformat(), + "timezone": "UTC", + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + mcp_server = getattr(fastmcp, "_mcp_server") + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + retry_interval=None, + json_response=False, + stateless=False, + security_settings=None, + ) + streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) + + auth_settings = AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ) + resource_url = auth_settings.resource_server_url + assert resource_url is not None + resource_metadata_url = build_resource_metadata_url(resource_url) + # We still define full protocol metadata for logging/reference, but PRM will not include mcp_auth_protocols + protocols_metadata = _protocol_metadata_list(settings) + auth_protocol_ids = [p.protocol_id for p in protocols_metadata] + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + + require_auth = RequireAuthMiddleware( + streamable_app, + required_scopes=[settings.mcp_scope], + resource_metadata_url=resource_metadata_url, + auth_protocols=auth_protocol_ids, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + + routes: list[Route] = [ + Route( + "/mcp", + endpoint=require_auth, + ), + ] + # PRM without mcp_auth_protocols: only authorization_servers/scopes + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat + # validator auto-filling mcp_auth_protocols from authorization_servers. + auth_protocols=[], + default_protocol=None, + protocol_preferences=None, + ) + ) + + # NOTE: OAuth-fallback variant intentionally does NOT add any unified discovery routes: + # - No /.well-known/authorization_servers + # - No /.well-known/authorization_servers/mcp + + middleware = [ + Middleware(AuthenticationMiddleware, backend=adapter), + Middleware(AuthContextMiddleware), + ] + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with session_manager.run(): + yield + + return Starlette( + debug=True, + routes=routes, + middleware=middleware, + lifespan=lifespan, + ) + + +@click.command() +@click.option("--port", default=8002, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", +) +@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") +@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") +def main( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, + dpop_enabled: bool, +) -> int: + """Run the multi-protocol MCP Resource Server (OAuth-fallback discovery).""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings) + logger.info("Multi-protocol RS (OAuth-fallback discovery) running on %s", settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py new file mode 100644 index 000000000..b02bccf3e --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py @@ -0,0 +1,81 @@ +"""OAuth token verifier using introspection (OAuth-fallback variant reuses same logic).""" + +import logging +from typing import Any, cast + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + if not self.introspection_endpoint.startswith( + ("https://", "http://localhost", "http://127.0.0.1") + ): + logger.warning("Rejecting unsafe introspection endpoint") + return None + + timeout = httpx.Timeout(10.0, connect=5.0) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + return None + data = response.json() + if not data.get("active", False): + return None + if self.validate_resource and not self._validate_resource(data): + return None + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), + ) + except Exception as e: + logger.warning("Token introspection failed: %s", e) + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + if not self.server_url or not self.resource_url: + return False + aud = token_data.get("aud") + if isinstance(aud, list): + for item in cast(list[str], aud): + if self._is_valid_resource(item): + return True + return False + if isinstance(aud, str): + return self._is_valid_resource(aud) + return False + + def _is_valid_resource(self, resource: str) -> bool: + if not self.resource_url: + return False + return check_resource_allowed( + requested_resource=self.resource_url, configured_resource=resource + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py new file mode 100644 index 000000000..dadaff77d --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py @@ -0,0 +1,2 @@ +"""MCP Resource Server (multiprotocol, path-only unified discovery variant).""" + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py new file mode 100644 index 000000000..dff14da53 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for multi-protocol MCP Resource Server (path-only unified discovery).""" + +import sys + +from mcp_simple_auth_multiprotocol_path_only.server import main + +sys.exit(main()) # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py new file mode 100644 index 000000000..7b88ee0a1 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py @@ -0,0 +1,103 @@ +"""Multi-protocol auth adapter for path-only unified discovery variant.""" + +import logging +import time +from typing import Any, cast + +from starlette.authentication import AuthCredentials, AuthenticationBackend +from starlette.requests import HTTPConnection, Request + +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import ( + APIKeyVerifier, + CredentialVerifier, + MultiProtocolAuthBackend, + OAuthTokenVerifier, +) + +logger = logging.getLogger(__name__) + + +class MutualTLSVerifier: + """Placeholder verifier for Mutual TLS.""" + + async def verify( + self, + request: Any, + dpop_verifier: Any = None, + ) -> AccessToken | None: + return None + + +def build_multiprotocol_backend( + oauth_token_verifier: Any, + api_key_valid_keys: set[str], + api_key_scopes: list[str] | None = None, + dpop_enabled: bool = False, +) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) + api_key_verifier = APIKeyVerifier( + valid_keys=api_key_valid_keys, + scopes=api_key_scopes or [], + ) + mtls_verifier: CredentialVerifier = MutualTLSVerifier() + backend = MultiProtocolAuthBackend( + verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] + ) + + dpop_verifier: DPoPProofVerifier | None = None + if dpop_enabled: + dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + return backend, dpop_verifier + + +class MultiProtocolAuthBackendAdapter(AuthenticationBackend): + """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" + + def __init__( + self, + backend: MultiProtocolAuthBackend, + dpop_verifier: DPoPProofVerifier | None = None, + ) -> None: + self._backend = backend + self._dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: + request = cast(Request, conn) + + dpop_header = request.headers.get("dpop") + if self._dpop_verifier is not None: + if dpop_header: + logger.info("DPoP proof present, verification enabled") + else: + logger.debug("DPoP verification enabled but no DPoP header in request") + elif dpop_header: + logger.debug("DPoP header present but verification not enabled (ignoring)") + + result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) + + if result is None: + if dpop_header and self._dpop_verifier is not None: + logger.warning("Authentication failed (DPoP proof may be invalid)") + else: + logger.debug("Authentication failed (no valid credentials)") + return None + + if result.expires_at is not None and result.expires_at < int(time.time()): + logger.warning("Token expired for client_id=%s", result.client_id) + return None + + if dpop_header and self._dpop_verifier is not None: + logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) + else: + logger.info("Authentication successful (client_id=%s)", result.client_id) + + return ( + AuthCredentials(result.scopes or []), + AuthenticatedUser(result), + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py new file mode 100644 index 000000000..5d5bbd80e --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py @@ -0,0 +1,263 @@ +""" +MCP Resource Server with multi-protocol auth (path-only unified discovery variant). + +This variant: +- PRM does NOT include mcp_auth_protocols (only authorization_servers) +- Exposes only path-relative unified discovery endpoint: /.well-known/authorization_servers/mcp +""" + +import contextlib +import datetime +import logging +from typing import Any, Literal + +import click +import uvicorn +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Route +from starlette.types import ASGIApp + +from mcp.server.auth.handlers.discovery import AuthorizationServersDiscoveryHandler +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_authorization_servers_discovery_routes, + create_protected_resource_routes, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.shared.auth import AuthProtocolMetadata + +from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the multi-protocol MCP Resource Server (path-only discovery).""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + host: str = "localhost" + port: int = 8002 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + mcp_scope: str = "user" + oauth_strict: bool = False + api_key_valid_keys: str = "demo-api-key-12345" + default_protocol: str = "oauth2" + protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + dpop_enabled: bool = False + + +def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: + """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" + auth_base = str(settings.auth_server_url).rstrip("/") + oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") + return [ + AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=oauth_metadata_url, + scopes_supported=[settings.mcp_scope], + ), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), + ] + + +def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: + """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" + out: dict[str, int] = {} + for part in prefs_str.split(","): + s = part.strip() + if ":" in s: + proto, prio = s.split(":", 1) + try: + out[proto.strip()] = int(prio.strip()) + except ValueError: + pass + return out + + +def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: + """Create Starlette app with MultiProtocolAuthBackend, PRM (no mcp_auth_protocols) and path-only discovery.""" + oauth_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, + ) + api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} + backend, dpop_verifier = build_multiprotocol_backend( + oauth_verifier, + api_key_keys, + api_key_scopes=[settings.mcp_scope], + dpop_enabled=settings.dpop_enabled, + ) + adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) + + fastmcp = FastMCP( + name="MCP Resource Server (multiprotocol, path-only discovery)", + instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (path-only discovery)", + host=settings.host, + port=settings.port, + auth=None, + ) + + @fastmcp.tool() + async def get_time() -> dict[str, Any]: + """Return current server time (requires auth).""" + now = datetime.datetime.now() + return { + "current_time": now.isoformat(), + "timezone": "UTC", + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + mcp_server = getattr(fastmcp, "_mcp_server") + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + retry_interval=None, + json_response=False, + stateless=False, + security_settings=None, + ) + streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) + + auth_settings = AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ) + resource_url = auth_settings.resource_server_url + assert resource_url is not None + resource_metadata_url = build_resource_metadata_url(resource_url) + protocols_metadata = _protocol_metadata_list(settings) + auth_protocol_ids = [p.protocol_id for p in protocols_metadata] + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + + require_auth = RequireAuthMiddleware( + streamable_app, + required_scopes=[settings.mcp_scope], + resource_metadata_url=resource_metadata_url, + auth_protocols=auth_protocol_ids, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + + routes: list[Route] = [ + Route( + "/mcp", + endpoint=require_auth, + ), + ] + # PRM without mcp_auth_protocols: only authorization_servers/scopes + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat + # validator auto-filling mcp_auth_protocols from authorization_servers. + auth_protocols=[], + default_protocol=None, + protocol_preferences=None, + ) + ) + + # Path-relative unified discovery endpoint (Way B: /.well-known/authorization_servers/mcp) + path_relative_handler = AuthorizationServersDiscoveryHandler( + protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + routes.append( + Route( + "/.well-known/authorization_servers/mcp", + endpoint=path_relative_handler.handle, + methods=["GET", "OPTIONS"], + ) + ) + + # NOTE: path-only variant intentionally does NOT add root-based unified discovery + # (no /.well-known/authorization_servers route) + + middleware = [ + Middleware(AuthenticationMiddleware, backend=adapter), + Middleware(AuthContextMiddleware), + ] + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with session_manager.run(): + yield + + return Starlette( + debug=True, + routes=routes, + middleware=middleware, + lifespan=lifespan, + ) + + +@click.command() +@click.option("--port", default=8002, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", +) +@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") +@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") +def main( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, + dpop_enabled: bool, +) -> int: + """Run the multi-protocol MCP Resource Server (path-only discovery).""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings) + logger.info("Multi-protocol RS (path-only discovery) running on %s", settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py new file mode 100644 index 000000000..e680c64df --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py @@ -0,0 +1,81 @@ +"""OAuth token verifier using introspection (path-only variant reuses same logic).""" + +import logging +from typing import Any, cast + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + if not self.introspection_endpoint.startswith( + ("https://", "http://localhost", "http://127.0.0.1") + ): + logger.warning("Rejecting unsafe introspection endpoint") + return None + + timeout = httpx.Timeout(10.0, connect=5.0) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + return None + data = response.json() + if not data.get("active", False): + return None + if self.validate_resource and not self._validate_resource(data): + return None + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), + ) + except Exception as e: + logger.warning("Token introspection failed: %s", e) + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + if not self.server_url or not self.resource_url: + return False + aud = token_data.get("aud") + if isinstance(aud, list): + for item in cast(list[str], aud): + if self._is_valid_resource(item): + return True + return False + if isinstance(aud, str): + return self._is_valid_resource(aud) + return False + + def _is_valid_resource(self, resource: str) -> bool: + if not self.resource_url: + return False + return check_resource_allowed( + requested_resource=self.resource_url, configured_resource=resource + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py new file mode 100644 index 000000000..b2c4f3b6c --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py @@ -0,0 +1,2 @@ +"""MCP Resource Server (multiprotocol, PRM-only discovery variant).""" + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py new file mode 100644 index 000000000..0ce22b982 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for multi-protocol MCP Resource Server (PRM-only discovery).""" + +import sys + +from mcp_simple_auth_multiprotocol_prm_only.server import main + +sys.exit(main()) # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py new file mode 100644 index 000000000..64b15ac7c --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py @@ -0,0 +1,108 @@ +"""Multi-protocol auth adapter for PRM-only discovery variant.""" + +import logging +import time +from typing import Any, cast + +from starlette.authentication import AuthCredentials, AuthenticationBackend +from starlette.requests import HTTPConnection, Request + +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import ( + APIKeyVerifier, + CredentialVerifier, + MultiProtocolAuthBackend, + OAuthTokenVerifier, +) + +logger = logging.getLogger(__name__) + + +class MutualTLSVerifier: + """ + Placeholder verifier for Mutual TLS. + + Does not validate client certificates; returns None. Real mTLS validation + would inspect the TLS connection for client certificate and verify it. + """ + + async def verify( + self, + request: Any, + dpop_verifier: Any = None, + ) -> AccessToken | None: + return None + + +def build_multiprotocol_backend( + oauth_token_verifier: Any, + api_key_valid_keys: set[str], + api_key_scopes: list[str] | None = None, + dpop_enabled: bool = False, +) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) + api_key_verifier = APIKeyVerifier( + valid_keys=api_key_valid_keys, + scopes=api_key_scopes or [], + ) + mtls_verifier: CredentialVerifier = MutualTLSVerifier() + backend = MultiProtocolAuthBackend( + verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] + ) + + dpop_verifier: DPoPProofVerifier | None = None + if dpop_enabled: + dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + return backend, dpop_verifier + + +class MultiProtocolAuthBackendAdapter(AuthenticationBackend): + """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" + + def __init__( + self, + backend: MultiProtocolAuthBackend, + dpop_verifier: DPoPProofVerifier | None = None, + ) -> None: + self._backend = backend + self._dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: + request = cast(Request, conn) + + dpop_header = request.headers.get("dpop") + if self._dpop_verifier is not None: + if dpop_header: + logger.info("DPoP proof present, verification enabled") + else: + logger.debug("DPoP verification enabled but no DPoP header in request") + elif dpop_header: + logger.debug("DPoP header present but verification not enabled (ignoring)") + + result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) + + if result is None: + if dpop_header and self._dpop_verifier is not None: + logger.warning("Authentication failed (DPoP proof may be invalid)") + else: + logger.debug("Authentication failed (no valid credentials)") + return None + + if result.expires_at is not None and result.expires_at < int(time.time()): + logger.warning("Token expired for client_id=%s", result.client_id) + return None + + if dpop_header and self._dpop_verifier is not None: + logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) + else: + logger.info("Authentication successful (client_id=%s)", result.client_id) + + return ( + AuthCredentials(result.scopes or []), + AuthenticatedUser(result), + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py new file mode 100644 index 000000000..cf5e65445 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py @@ -0,0 +1,245 @@ +""" +MCP Resource Server with multi-protocol auth (PRM-only discovery variant). + +This variant: +- Exposes PRM with mcp_auth_protocols and authorization_servers +- Does NOT expose unified discovery endpoints (authorization_servers, authorization_servers/mcp) +""" + +import contextlib +import datetime +import logging +from typing import Any, Literal + +import click +import uvicorn +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Route +from starlette.types import ASGIApp + +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_protected_resource_routes, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.shared.auth import AuthProtocolMetadata + +from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the multi-protocol MCP Resource Server (PRM-only discovery).""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + host: str = "localhost" + port: int = 8002 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + mcp_scope: str = "user" + oauth_strict: bool = False + api_key_valid_keys: str = "demo-api-key-12345" + default_protocol: str = "oauth2" + protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + dpop_enabled: bool = False + + +def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: + """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" + auth_base = str(settings.auth_server_url).rstrip("/") + oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") + return [ + AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=oauth_metadata_url, + scopes_supported=[settings.mcp_scope], + ), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), + ] + + +def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: + """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" + out: dict[str, int] = {} + for part in prefs_str.split(","): + s = part.strip() + if ":" in s: + proto, prio = s.split(":", 1) + try: + out[proto.strip()] = int(prio.strip()) + except ValueError: + pass + return out + + +def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: + """Create Starlette app with MultiProtocolAuthBackend and PRM routes only.""" + oauth_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, + ) + api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} + backend, dpop_verifier = build_multiprotocol_backend( + oauth_verifier, + api_key_keys, + api_key_scopes=[settings.mcp_scope], + dpop_enabled=settings.dpop_enabled, + ) + adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) + + fastmcp = FastMCP( + name="MCP Resource Server (multiprotocol, PRM-only)", + instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (PRM-only discovery)", + host=settings.host, + port=settings.port, + auth=None, + ) + + @fastmcp.tool() + async def get_time() -> dict[str, Any]: + """Return current server time (requires auth).""" + now = datetime.datetime.now() + return { + "current_time": now.isoformat(), + "timezone": "UTC", + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + mcp_server = getattr(fastmcp, "_mcp_server") + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + retry_interval=None, + json_response=False, + stateless=False, + security_settings=None, + ) + streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) + + auth_settings = AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ) + resource_url = auth_settings.resource_server_url + assert resource_url is not None + resource_metadata_url = build_resource_metadata_url(resource_url) + protocols_metadata = _protocol_metadata_list(settings) + auth_protocol_ids = [p.protocol_id for p in protocols_metadata] + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + + require_auth = RequireAuthMiddleware( + streamable_app, + required_scopes=[settings.mcp_scope], + resource_metadata_url=resource_metadata_url, + auth_protocols=auth_protocol_ids, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + + routes: list[Route] = [ + Route( + "/mcp", + endpoint=require_auth, + ), + ] + # PRM with mcp_auth_protocols and authorization_servers + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + auth_protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + ) + # NOTE: PRM-only variant intentionally does NOT add unified discovery routes: + # - No root-based /.well-known/authorization_servers + # - No path-relative /.well-known/authorization_servers/mcp + + middleware = [ + Middleware(AuthenticationMiddleware, backend=adapter), + Middleware(AuthContextMiddleware), + ] + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with session_manager.run(): + yield + + return Starlette( + debug=True, + routes=routes, + middleware=middleware, + lifespan=lifespan, + ) + + +@click.command() +@click.option("--port", default=8002, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", +) +@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") +@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") +def main( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, + dpop_enabled: bool, +) -> int: + """Run the multi-protocol MCP Resource Server (PRM-only discovery).""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings) + logger.info("Multi-protocol RS (PRM-only) running on %s", settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py new file mode 100644 index 000000000..92016a525 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py @@ -0,0 +1,81 @@ +"""OAuth token verifier using introspection (PRM-only variant reuses same logic).""" + +import logging +from typing import Any, cast + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + if not self.introspection_endpoint.startswith( + ("https://", "http://localhost", "http://127.0.0.1") + ): + logger.warning("Rejecting unsafe introspection endpoint") + return None + + timeout = httpx.Timeout(10.0, connect=5.0) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + return None + data = response.json() + if not data.get("active", False): + return None + if self.validate_resource and not self._validate_resource(data): + return None + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), + ) + except Exception as e: + logger.warning("Token introspection failed: %s", e) + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + if not self.server_url or not self.resource_url: + return False + aud = token_data.get("aud") + if isinstance(aud, list): + for item in cast(list[str], aud): + if self._is_valid_resource(item): + return True + return False + if isinstance(aud, str): + return self._is_valid_resource(aud) + return False + + def _is_valid_resource(self, resource: str) -> bool: + if not self.resource_url: + return False + return check_resource_allowed( + requested_resource=self.resource_url, configured_resource=resource + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py new file mode 100644 index 000000000..0a326f786 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py @@ -0,0 +1,2 @@ +"""MCP Resource Server (multiprotocol, root-only unified discovery variant).""" + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py new file mode 100644 index 000000000..1f187b070 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py @@ -0,0 +1,8 @@ +"""Entry point for multi-protocol MCP Resource Server (root-only unified discovery).""" + +import sys + +from mcp_simple_auth_multiprotocol_root_only.server import main + +sys.exit(main()) # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py new file mode 100644 index 000000000..26db620e7 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py @@ -0,0 +1,103 @@ +"""Multi-protocol auth adapter for root-only unified discovery variant.""" + +import logging +import time +from typing import Any, cast + +from starlette.authentication import AuthCredentials, AuthenticationBackend +from starlette.requests import HTTPConnection, Request + +from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.verifiers import ( + APIKeyVerifier, + CredentialVerifier, + MultiProtocolAuthBackend, + OAuthTokenVerifier, +) + +logger = logging.getLogger(__name__) + + +class MutualTLSVerifier: + """Placeholder verifier for Mutual TLS.""" + + async def verify( + self, + request: Any, + dpop_verifier: Any = None, + ) -> AccessToken | None: + return None + + +def build_multiprotocol_backend( + oauth_token_verifier: Any, + api_key_valid_keys: set[str], + api_key_scopes: list[str] | None = None, + dpop_enabled: bool = False, +) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: + """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" + oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) + api_key_verifier = APIKeyVerifier( + valid_keys=api_key_valid_keys, + scopes=api_key_scopes or [], + ) + mtls_verifier: CredentialVerifier = MutualTLSVerifier() + backend = MultiProtocolAuthBackend( + verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] + ) + + dpop_verifier: DPoPProofVerifier | None = None + if dpop_enabled: + dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + return backend, dpop_verifier + + +class MultiProtocolAuthBackendAdapter(AuthenticationBackend): + """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" + + def __init__( + self, + backend: MultiProtocolAuthBackend, + dpop_verifier: DPoPProofVerifier | None = None, + ) -> None: + self._backend = backend + self._dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: + request = cast(Request, conn) + + dpop_header = request.headers.get("dpop") + if self._dpop_verifier is not None: + if dpop_header: + logger.info("DPoP proof present, verification enabled") + else: + logger.debug("DPoP verification enabled but no DPoP header in request") + elif dpop_header: + logger.debug("DPoP header present but verification not enabled (ignoring)") + + result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) + + if result is None: + if dpop_header and self._dpop_verifier is not None: + logger.warning("Authentication failed (DPoP proof may be invalid)") + else: + logger.debug("Authentication failed (no valid credentials)") + return None + + if result.expires_at is not None and result.expires_at < int(time.time()): + logger.warning("Token expired for client_id=%s", result.client_id) + return None + + if dpop_header and self._dpop_verifier is not None: + logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) + else: + logger.info("Authentication successful (client_id=%s)", result.client_id) + + return ( + AuthCredentials(result.scopes or []), + AuthenticatedUser(result), + ) + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py new file mode 100644 index 000000000..134fc1e77 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py @@ -0,0 +1,258 @@ +""" +MCP Resource Server with multi-protocol auth (root-only unified discovery variant). + +This variant: +- PRM does NOT include mcp_auth_protocols (only authorization_servers) +- Exposes only root unified discovery endpoint: /.well-known/authorization_servers +""" + +import contextlib +import datetime +import logging +from typing import Any, Literal + +import click +import uvicorn +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Route +from starlette.types import ASGIApp + +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import ( + build_resource_metadata_url, + create_authorization_servers_discovery_routes, + create_protected_resource_routes, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.shared.auth import AuthProtocolMetadata + +from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the multi-protocol MCP Resource Server (root-only discovery).""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + host: str = "localhost" + port: int = 8002 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + mcp_scope: str = "user" + oauth_strict: bool = False + api_key_valid_keys: str = "demo-api-key-12345" + default_protocol: str = "oauth2" + protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" + dpop_enabled: bool = False + + +def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: + """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" + auth_base = str(settings.auth_server_url).rstrip("/") + oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") + return [ + AuthProtocolMetadata( + protocol_id="oauth2", + protocol_version="2.0", + metadata_url=oauth_metadata_url, + scopes_supported=[settings.mcp_scope], + ), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), + ] + + +def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: + """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" + out: dict[str, int] = {} + for part in prefs_str.split(","): + s = part.strip() + if ":" in s: + proto, prio = s.split(":", 1) + try: + out[proto.strip()] = int(prio.strip()) + except ValueError: + pass + return out + + +def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: + """Create Starlette app with MultiProtocolAuthBackend, PRM (no mcp_auth_protocols) and root-only discovery.""" + oauth_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, + ) + api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} + backend, dpop_verifier = build_multiprotocol_backend( + oauth_verifier, + api_key_keys, + api_key_scopes=[settings.mcp_scope], + dpop_enabled=settings.dpop_enabled, + ) + adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) + + fastmcp = FastMCP( + name="MCP Resource Server (multiprotocol, root-only discovery)", + instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (root-only discovery)", + host=settings.host, + port=settings.port, + auth=None, + ) + + @fastmcp.tool() + async def get_time() -> dict[str, Any]: + """Return current server time (requires auth).""" + now = datetime.datetime.now() + return { + "current_time": now.isoformat(), + "timezone": "UTC", + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + mcp_server = getattr(fastmcp, "_mcp_server") + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + retry_interval=None, + json_response=False, + stateless=False, + security_settings=None, + ) + streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) + + auth_settings = AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ) + resource_url = auth_settings.resource_server_url + assert resource_url is not None + resource_metadata_url = build_resource_metadata_url(resource_url) + protocols_metadata = _protocol_metadata_list(settings) + auth_protocol_ids = [p.protocol_id for p in protocols_metadata] + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + + require_auth = RequireAuthMiddleware( + streamable_app, + required_scopes=[settings.mcp_scope], + resource_metadata_url=resource_metadata_url, + auth_protocols=auth_protocol_ids, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + + routes: list[Route] = [ + Route( + "/mcp", + endpoint=require_auth, + ), + ] + + # PRM without MCP extensions: provide RFC 9728 authorization_servers/scopes only. + # This allows OAuth to locate the AS without letting clients discover protocols via PRM mcp_auth_protocols. + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat + # validator auto-filling mcp_auth_protocols from authorization_servers. + auth_protocols=[], + default_protocol=None, + protocol_preferences=None, + ) + ) + + # Root-based unified discovery endpoint only (/.well-known/authorization_servers) + routes.extend( + create_authorization_servers_discovery_routes( + protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs if protocol_prefs else None, + ) + ) + # NOTE: root-only variant intentionally does NOT add path-relative unified discovery + # (no /.well-known/authorization_servers/mcp route) + + middleware = [ + Middleware(AuthenticationMiddleware, backend=adapter), + Middleware(AuthContextMiddleware), + ] + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette): + async with session_manager.run(): + yield + + return Starlette( + debug=True, + routes=routes, + middleware=middleware, + lifespan=lifespan, + ) + + +@click.command() +@click.option("--port", default=8002, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", +) +@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") +@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") +@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") +def main( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, + dpop_enabled: bool, +) -> int: + """Run the multi-protocol MCP Resource Server (root-only discovery).""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings) + logger.info("Multi-protocol RS (root-only discovery) running on %s", settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py new file mode 100644 index 000000000..51fc081da --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py @@ -0,0 +1,81 @@ +"""OAuth token verifier using introspection (root-only variant reuses same logic).""" + +import logging +from typing import Any, cast + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + if not self.introspection_endpoint.startswith( + ("https://", "http://localhost", "http://127.0.0.1") + ): + logger.warning("Rejecting unsafe introspection endpoint") + return None + + timeout = httpx.Timeout(10.0, connect=5.0) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + return None + data = response.json() + if not data.get("active", False): + return None + if self.validate_resource and not self._validate_resource(data): + return None + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), + ) + except Exception as e: + logger.warning("Token introspection failed: %s", e) + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + if not self.server_url or not self.resource_url: + return False + aud = token_data.get("aud") + if isinstance(aud, list): + for item in cast(list[str], aud): + if self._is_valid_resource(item): + return True + return False + if isinstance(aud, str): + return self._is_valid_resource(aud) + return False + + def _is_valid_resource(self, resource: str) -> bool: + if not self.resource_url: + return False + return check_resource_allowed( + requested_resource=self.resource_url, configured_resource=resource + ) + diff --git a/examples/servers/simple-auth-multiprotocol/pyproject.toml b/examples/servers/simple-auth-multiprotocol/pyproject.toml index eaeb20396..78614688c 100644 --- a/examples/servers/simple-auth-multiprotocol/pyproject.toml +++ b/examples/servers/simple-auth-multiprotocol/pyproject.toml @@ -19,13 +19,23 @@ dependencies = [ [project.scripts] mcp-simple-auth-multiprotocol-rs = "mcp_simple_auth_multiprotocol.server:main" +mcp-simple-auth-multiprotocol-prm-only-rs = "mcp_simple_auth_multiprotocol_prm_only.server:main" +mcp-simple-auth-multiprotocol-path-only-rs = "mcp_simple_auth_multiprotocol_path_only.server:main" +mcp-simple-auth-multiprotocol-root-only-rs = "mcp_simple_auth_multiprotocol_root_only.server:main" +mcp-simple-auth-multiprotocol-oauth-fallback-rs = "mcp_simple_auth_multiprotocol_oauth_fallback.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["mcp_simple_auth_multiprotocol"] +packages = [ + "mcp_simple_auth_multiprotocol", + "mcp_simple_auth_multiprotocol_prm_only", + "mcp_simple_auth_multiprotocol_path_only", + "mcp_simple_auth_multiprotocol_root_only", + "mcp_simple_auth_multiprotocol_oauth_fallback", +] [dependency-groups] dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index 3a3895cc5..a8efbbc41 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -41,6 +41,10 @@ class SimpleAuthSettings(BaseSettings): # MCP OAuth scope mcp_scope: str = "user" + # Demo client for client_credentials grant (optional) + demo_cc_client_id: str = "demo-client-id" + demo_cc_client_secret: str = "demo-client-secret" + class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): """Simple OAuth provider for demo purposes. @@ -62,6 +66,17 @@ def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_ # Store authenticated user information self.user_data: dict[str, dict[str, Any]] = {} + # Pre-register a demo client_credentials client (for M2M examples/tests). + if self.settings.demo_cc_client_id and self.settings.demo_cc_client_secret: + self.clients[self.settings.demo_cc_client_id] = OAuthClientInformationFull( + redirect_uris=None, + client_id=self.settings.demo_cc_client_id, + client_secret=self.settings.demo_cc_client_secret, + grant_types=["client_credentials"], + token_endpoint_auth_method="client_secret_post", + scope=self.settings.mcp_scope, + ) + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: """Get OAuth client information.""" return self.clients.get(client_id) @@ -263,6 +278,37 @@ async def exchange_refresh_token( """Exchange refresh token - not supported in this example.""" raise NotImplementedError("Refresh tokens not supported") + async def exchange_client_credentials( + self, + client: OAuthClientInformationFull, + *, + scopes: list[str], + resource: str | None = None, + ) -> OAuthToken: + """Exchange client credentials for an access token (client_credentials grant).""" + if not client.client_id: + raise TokenError(error="invalid_client", error_description="Missing client_id") + + # Default to MCP scope if none provided + effective_scopes = scopes or [self.settings.mcp_scope] + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + self.tokens[mcp_token] = AccessToken( + token=mcp_token, + client_id=client.client_id, + scopes=effective_scopes, + expires_at=int(time.time()) + 3600, + resource=resource, + ) + + return OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(effective_scopes), + ) + # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore """Revoke a token.""" From 8c1b0605694866aa7238594b51cd12d04ea782f2 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 10:00:26 +0800 Subject: [PATCH 34/64] refactor(auth): reduce async_auth_flow complexity, remove noqa --- src/mcp/client/auth/multi_protocol.py | 212 ++++++++++++++++++++++++-- src/mcp/client/auth/utils.py | 59 +++++-- 2 files changed, 246 insertions(+), 25 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index a28c914dc..fdfbc97c9 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -23,7 +23,7 @@ import logging import math import time -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from typing import Any, Protocol, cast from urllib.parse import urljoin @@ -36,6 +36,7 @@ from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.auth.utils import ( + build_authorization_servers_discovery_urls, build_protected_resource_metadata_discovery_urls, create_oauth_metadata_request, extract_auth_protocols_from_www_auth, @@ -44,6 +45,7 @@ extract_protocol_preferences_from_www_auth, extract_resource_metadata_from_www_auth, extract_scope_from_www_auth, + format_json_for_logging, handle_protected_resource_response, ) from mcp.shared.auth import ( @@ -60,6 +62,42 @@ UNSPECIFIED_PROTOCOL_PREFERENCE: float = math.inf +class _DiscoveryResult: + """Mutable holder for 401 discovery results (PRM, protocols, attempted URLs).""" + + def __init__(self) -> None: + self.prm: ProtectedResourceMetadata | None = None + self.protocols_metadata: list[AuthProtocolMetadata] = [] + self.discovery_attempted_urls: list[str] = [] + + +def _build_protocol_candidates( + available: list[str], + default_protocol: str | None, + protocol_preferences: Mapping[str, float | int] | None, +) -> list[str]: + """Build ordered protocol candidate list: default first, then by preference, then rest.""" + candidates: list[str] = [] + seen: set[str] = set() + + def push(pid: str | None) -> None: + if not pid or pid in seen: + return + seen.add(pid) + candidates.append(pid) + + push(default_protocol) + if protocol_preferences: + for pid in sorted( + available, + key=lambda p: protocol_preferences.get(p, UNSPECIFIED_PROTOCOL_PREFERENCE), + ): + push(pid) + for pid in available: + push(pid) + return candidates + + class TokenStorage(Protocol): """ 凭证存储协议(multi_protocol 契约)。 @@ -278,6 +316,66 @@ async def _handle_403_response( if error or scope: logger.debug("403 WWW-Authenticate: error=%s scope=%s", error, scope) +<<<<<<< HEAD +======= + async def _run_401_discovery_requests( + self, + resource_metadata_url: str | None, + server_url: str, + result: _DiscoveryResult, + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Run PRM + protocol discovery + OAuth fallback; yield discovery requests, store outcome in result.""" + prm_urls = build_protected_resource_metadata_discovery_urls(resource_metadata_url, server_url) + for url in prm_urls: + logger.debug("[Auth discovery] Trying PRM endpoint: %s", url) + prm_req = create_oauth_metadata_request(url) + prm_resp = yield prm_req + result.prm = await handle_protected_resource_response(prm_resp) + if result.prm is not None: + break + + prm = result.prm + if prm and prm.mcp_auth_protocols: + protocol_ids = [m.protocol_id for m in prm.mcp_auth_protocols] + auth_servers = [str(u) for u in (prm.authorization_servers or [])] + logger.debug( + "[Auth discovery] Using PRM mcp_auth_protocols (priority 1): " + "protocol_ids=%s, authorization_servers=%s", + protocol_ids, + auth_servers, + ) + result.protocols_metadata = list(prm.mcp_auth_protocols) + else: + discovery_urls = build_authorization_servers_discovery_urls(server_url) + for discovery_url in discovery_urls: + result.discovery_attempted_urls.append(discovery_url) + logger.debug("[Auth discovery] Trying unified discovery endpoint: %s", discovery_url) + discovery_req = create_oauth_metadata_request(discovery_url) + discovery_resp = yield discovery_req + result.protocols_metadata = await self._parse_protocols_from_discovery_response( + discovery_resp, None + ) + if result.protocols_metadata: + logger.debug("Unified discovery succeeded at %s", discovery_url) + break + + if not result.protocols_metadata and result.prm and result.prm.authorization_servers: + logger.debug("Unified discovery failed, falling back to OAuth protocol discovery") + oauth_protocol = self._get_protocol("oauth2") + if oauth_protocol and hasattr(oauth_protocol, "discover_metadata"): + try: + oauth_metadata = await oauth_protocol.discover_metadata( + metadata_url=None, + prm=result.prm, + http_client=self._http_client, + ) + if oauth_metadata: + result.protocols_metadata = [oauth_metadata] + logger.debug("OAuth protocol discovery succeeded") + except Exception as e: + logger.debug("OAuth protocol discovery failed: %s", e) + +>>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: @@ -308,6 +406,7 @@ async def async_auth_flow( attempted_any = False last_auth_error: Exception | None = None +<<<<<<< HEAD # Step 1: PRM discovery (yield) prm: ProtectedResourceMetadata | None = None prm_urls = build_protected_resource_metadata_discovery_urls( @@ -331,27 +430,63 @@ async def async_auth_flow( discovery_resp, prm ) +======= + discovery_result = _DiscoveryResult() + discovery_gen = self._run_401_discovery_requests( + resource_metadata_url, server_url, discovery_result + ) + try: + req = await discovery_gen.__anext__() + except StopAsyncIteration: + pass + else: + while True: + resp = yield req + try: + req = await discovery_gen.asend(resp) + except StopAsyncIteration: + break + + prm = discovery_result.prm + protocols_metadata = discovery_result.protocols_metadata + discovery_attempted_urls = discovery_result.discovery_attempted_urls +>>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) available = ( [m.protocol_id for m in protocols_metadata] if protocols_metadata else (auth_protocols_header or []) ) if not available: +<<<<<<< HEAD logger.debug("No available protocols from discovery or WWW-Authenticate") else: # Select protocol candidates based on server hints, but only # attempt protocols that are actually injected as instances. candidates: list[str] = [] seen: set[str] = set() - - def _push(pid: str | None) -> None: - if not pid: - return - if pid in seen: - return - seen.add(pid) - candidates.append(pid) - +======= + error_msg = ( + f"Failed to discover authentication protocols. " + f"Tried URLs: {discovery_attempted_urls}. " + f"PRM available: {prm is not None}, " + f"PRM has authorization_servers: {bool(prm.authorization_servers if prm else False)}, " + f"WWW-Authenticate protocols: {auth_protocols_header}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) +>>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) + + candidates = _build_protocol_candidates( + available, default_protocol, protocol_preferences + ) + for selected_id in candidates: + protocol = self._get_protocol(selected_id) + if protocol is None: + logger.debug("Protocol %s not injected as instance; skipping", selected_id) + continue + attempted_any = True + +<<<<<<< HEAD # Default protocol first (server recommendation) _push(default_protocol) # Then order by preferences if provided @@ -381,8 +516,58 @@ def _push(pid: str | None) -> None: for m in protocols_metadata: if m.protocol_id == selected_id: protocol_metadata = m +======= + protocol_metadata = None + if protocols_metadata: + for m in protocols_metadata: + if m.protocol_id == selected_id: + protocol_metadata = m + break + + try: + if selected_id == "oauth2": + oauth_protocol = protocol + provider = OAuthClientProvider( + server_url=server_url, + client_metadata=getattr(oauth_protocol, "_client_metadata"), + storage=cast(OAuth2TokenStorage, self.storage), + redirect_handler=getattr(oauth_protocol, "_redirect_handler", None), + callback_handler=getattr(oauth_protocol, "_callback_handler", None), + timeout=getattr(oauth_protocol, "_timeout", self.timeout), + client_metadata_url=getattr(oauth_protocol, "_client_metadata_url", None), + fixed_client_info=getattr(oauth_protocol, "_fixed_client_info", None), + ) + provider.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) + oauth_gen = oauth_401_flow_generator( + provider, original_request, original_401_response, initial_prm=prm + ) + auth_req = await oauth_gen.__anext__() + while True: + auth_resp = yield auth_req + try: + auth_req = await oauth_gen.asend(auth_resp) + except StopAsyncIteration: +>>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) break + else: + context = AuthContext( + server_url=server_url, + storage=self.storage, + protocol_id=selected_id, + protocol_metadata=protocol_metadata, + current_credentials=None, + dpop_storage=self.dpop_storage, + dpop_enabled=self.dpop_enabled, + http_client=self._http_client, + resource_metadata_url=resource_metadata_url, + protected_resource_metadata=prm, + scope_from_www_auth=extract_scope_from_www_auth(original_401_response), + ) + credentials = await protocol.authenticate(context) + to_store = _credentials_to_storage(credentials) + await self.storage.set_tokens(to_store) +<<<<<<< HEAD try: if selected_id == "oauth2": # OAuth: drive shared generator (single client, yield) @@ -439,6 +624,13 @@ def _push(pid: str | None) -> None: "Protocol %s authentication failed: %s", selected_id, e ) continue +======= + break + except Exception as e: + last_auth_error = e + logger.debug("Protocol %s authentication failed: %s", selected_id, e) + continue +>>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) credentials = await self._get_credentials() if credentials and self._is_credentials_valid(credentials): diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 706645658..bae906bc4 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -134,6 +134,35 @@ def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, return preferences if preferences else None +def build_authorization_servers_discovery_urls(resource_url: str) -> list[str]: + """Build ordered list of unified discovery URLs. + + Tries a path-relative discovery URL first (if resource_url contains a path), + then falls back to the host-root discovery URL. + """ + parsed = urlparse(resource_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + urls: list[str] = [] + + # Path-relative: https://host//.well-known/authorization_servers + if parsed.path and parsed.path != "/": + path = parsed.path.rstrip("/") + urls.append(urljoin(base_url, f"{path}/.well-known/authorization_servers")) + + # Root: https://host/.well-known/authorization_servers + urls.append(urljoin(base_url, "/.well-known/authorization_servers")) + + # De-duplicate while preserving order. + seen: set[str] = set() + unique: list[str] = [] + for url in urls: + if url not in seen: + seen.add(url) + unique.append(url) + return unique + + async def discover_authorization_servers( resource_url: str, http_client: AsyncClient, @@ -155,21 +184,21 @@ async def discover_authorization_servers( Returns: List of protocol metadata; empty if discovery fails and no PRM fallback. """ - # 1. Unified discovery endpoint (path-relative to resource_url) - discovery_url = urljoin(resource_url.rstrip("/") + "/", ".well-known/authorization_servers") - try: - response = await http_client.get(discovery_url) - if response.status_code == 200: - content = await response.aread() - data = json.loads(content) - raw = data.get("protocols") - protocols_data: list[dict[str, Any]] = cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] - if protocols_data: - return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] - except (ValidationError, ValueError, KeyError, TypeError) as e: - logger.debug("Unified authorization_servers discovery failed: %s", e) - except Exception as e: - logger.debug("Unified authorization_servers request failed: %s", e) + # 1. Unified discovery endpoint (path-relative first, then root) + for discovery_url in build_authorization_servers_discovery_urls(resource_url): + try: + response = await http_client.get(discovery_url) + if response.status_code == 200: + content = await response.aread() + data = json.loads(content) + raw = data.get("protocols") + protocols_data: list[dict[str, Any]] = cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] + if protocols_data: + return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] + except (ValidationError, ValueError, KeyError, TypeError) as e: + logger.debug("Unified authorization_servers discovery failed (%s): %s", discovery_url, e) + except Exception as e: + logger.debug("Unified authorization_servers request failed (%s): %s", discovery_url, e) # 2. Fallback: use protocol list from PRM if prm is not None and prm.mcp_auth_protocols: From de25956d6c0717af8d12772d65caf7e1d2227d56 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 11:12:59 +0800 Subject: [PATCH 35/64] fix(auth): restore multiprotocol provider after refactor --- src/mcp/client/auth/multi_protocol.py | 212 ++------------------------ 1 file changed, 10 insertions(+), 202 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index fdfbc97c9..a28c914dc 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -23,7 +23,7 @@ import logging import math import time -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator from typing import Any, Protocol, cast from urllib.parse import urljoin @@ -36,7 +36,6 @@ from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.auth.utils import ( - build_authorization_servers_discovery_urls, build_protected_resource_metadata_discovery_urls, create_oauth_metadata_request, extract_auth_protocols_from_www_auth, @@ -45,7 +44,6 @@ extract_protocol_preferences_from_www_auth, extract_resource_metadata_from_www_auth, extract_scope_from_www_auth, - format_json_for_logging, handle_protected_resource_response, ) from mcp.shared.auth import ( @@ -62,42 +60,6 @@ UNSPECIFIED_PROTOCOL_PREFERENCE: float = math.inf -class _DiscoveryResult: - """Mutable holder for 401 discovery results (PRM, protocols, attempted URLs).""" - - def __init__(self) -> None: - self.prm: ProtectedResourceMetadata | None = None - self.protocols_metadata: list[AuthProtocolMetadata] = [] - self.discovery_attempted_urls: list[str] = [] - - -def _build_protocol_candidates( - available: list[str], - default_protocol: str | None, - protocol_preferences: Mapping[str, float | int] | None, -) -> list[str]: - """Build ordered protocol candidate list: default first, then by preference, then rest.""" - candidates: list[str] = [] - seen: set[str] = set() - - def push(pid: str | None) -> None: - if not pid or pid in seen: - return - seen.add(pid) - candidates.append(pid) - - push(default_protocol) - if protocol_preferences: - for pid in sorted( - available, - key=lambda p: protocol_preferences.get(p, UNSPECIFIED_PROTOCOL_PREFERENCE), - ): - push(pid) - for pid in available: - push(pid) - return candidates - - class TokenStorage(Protocol): """ 凭证存储协议(multi_protocol 契约)。 @@ -316,66 +278,6 @@ async def _handle_403_response( if error or scope: logger.debug("403 WWW-Authenticate: error=%s scope=%s", error, scope) -<<<<<<< HEAD -======= - async def _run_401_discovery_requests( - self, - resource_metadata_url: str | None, - server_url: str, - result: _DiscoveryResult, - ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """Run PRM + protocol discovery + OAuth fallback; yield discovery requests, store outcome in result.""" - prm_urls = build_protected_resource_metadata_discovery_urls(resource_metadata_url, server_url) - for url in prm_urls: - logger.debug("[Auth discovery] Trying PRM endpoint: %s", url) - prm_req = create_oauth_metadata_request(url) - prm_resp = yield prm_req - result.prm = await handle_protected_resource_response(prm_resp) - if result.prm is not None: - break - - prm = result.prm - if prm and prm.mcp_auth_protocols: - protocol_ids = [m.protocol_id for m in prm.mcp_auth_protocols] - auth_servers = [str(u) for u in (prm.authorization_servers or [])] - logger.debug( - "[Auth discovery] Using PRM mcp_auth_protocols (priority 1): " - "protocol_ids=%s, authorization_servers=%s", - protocol_ids, - auth_servers, - ) - result.protocols_metadata = list(prm.mcp_auth_protocols) - else: - discovery_urls = build_authorization_servers_discovery_urls(server_url) - for discovery_url in discovery_urls: - result.discovery_attempted_urls.append(discovery_url) - logger.debug("[Auth discovery] Trying unified discovery endpoint: %s", discovery_url) - discovery_req = create_oauth_metadata_request(discovery_url) - discovery_resp = yield discovery_req - result.protocols_metadata = await self._parse_protocols_from_discovery_response( - discovery_resp, None - ) - if result.protocols_metadata: - logger.debug("Unified discovery succeeded at %s", discovery_url) - break - - if not result.protocols_metadata and result.prm and result.prm.authorization_servers: - logger.debug("Unified discovery failed, falling back to OAuth protocol discovery") - oauth_protocol = self._get_protocol("oauth2") - if oauth_protocol and hasattr(oauth_protocol, "discover_metadata"): - try: - oauth_metadata = await oauth_protocol.discover_metadata( - metadata_url=None, - prm=result.prm, - http_client=self._http_client, - ) - if oauth_metadata: - result.protocols_metadata = [oauth_metadata] - logger.debug("OAuth protocol discovery succeeded") - except Exception as e: - logger.debug("OAuth protocol discovery failed: %s", e) - ->>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: @@ -406,7 +308,6 @@ async def async_auth_flow( attempted_any = False last_auth_error: Exception | None = None -<<<<<<< HEAD # Step 1: PRM discovery (yield) prm: ProtectedResourceMetadata | None = None prm_urls = build_protected_resource_metadata_discovery_urls( @@ -430,63 +331,27 @@ async def async_auth_flow( discovery_resp, prm ) -======= - discovery_result = _DiscoveryResult() - discovery_gen = self._run_401_discovery_requests( - resource_metadata_url, server_url, discovery_result - ) - try: - req = await discovery_gen.__anext__() - except StopAsyncIteration: - pass - else: - while True: - resp = yield req - try: - req = await discovery_gen.asend(resp) - except StopAsyncIteration: - break - - prm = discovery_result.prm - protocols_metadata = discovery_result.protocols_metadata - discovery_attempted_urls = discovery_result.discovery_attempted_urls ->>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) available = ( [m.protocol_id for m in protocols_metadata] if protocols_metadata else (auth_protocols_header or []) ) if not available: -<<<<<<< HEAD logger.debug("No available protocols from discovery or WWW-Authenticate") else: # Select protocol candidates based on server hints, but only # attempt protocols that are actually injected as instances. candidates: list[str] = [] seen: set[str] = set() -======= - error_msg = ( - f"Failed to discover authentication protocols. " - f"Tried URLs: {discovery_attempted_urls}. " - f"PRM available: {prm is not None}, " - f"PRM has authorization_servers: {bool(prm.authorization_servers if prm else False)}, " - f"WWW-Authenticate protocols: {auth_protocols_header}" - ) - logger.error(error_msg) - raise RuntimeError(error_msg) ->>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) - - candidates = _build_protocol_candidates( - available, default_protocol, protocol_preferences - ) - for selected_id in candidates: - protocol = self._get_protocol(selected_id) - if protocol is None: - logger.debug("Protocol %s not injected as instance; skipping", selected_id) - continue - attempted_any = True - -<<<<<<< HEAD + + def _push(pid: str | None) -> None: + if not pid: + return + if pid in seen: + return + seen.add(pid) + candidates.append(pid) + # Default protocol first (server recommendation) _push(default_protocol) # Then order by preferences if provided @@ -516,58 +381,8 @@ async def async_auth_flow( for m in protocols_metadata: if m.protocol_id == selected_id: protocol_metadata = m -======= - protocol_metadata = None - if protocols_metadata: - for m in protocols_metadata: - if m.protocol_id == selected_id: - protocol_metadata = m - break - - try: - if selected_id == "oauth2": - oauth_protocol = protocol - provider = OAuthClientProvider( - server_url=server_url, - client_metadata=getattr(oauth_protocol, "_client_metadata"), - storage=cast(OAuth2TokenStorage, self.storage), - redirect_handler=getattr(oauth_protocol, "_redirect_handler", None), - callback_handler=getattr(oauth_protocol, "_callback_handler", None), - timeout=getattr(oauth_protocol, "_timeout", self.timeout), - client_metadata_url=getattr(oauth_protocol, "_client_metadata_url", None), - fixed_client_info=getattr(oauth_protocol, "_fixed_client_info", None), - ) - provider.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) - oauth_gen = oauth_401_flow_generator( - provider, original_request, original_401_response, initial_prm=prm - ) - auth_req = await oauth_gen.__anext__() - while True: - auth_resp = yield auth_req - try: - auth_req = await oauth_gen.asend(auth_resp) - except StopAsyncIteration: ->>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) break - else: - context = AuthContext( - server_url=server_url, - storage=self.storage, - protocol_id=selected_id, - protocol_metadata=protocol_metadata, - current_credentials=None, - dpop_storage=self.dpop_storage, - dpop_enabled=self.dpop_enabled, - http_client=self._http_client, - resource_metadata_url=resource_metadata_url, - protected_resource_metadata=prm, - scope_from_www_auth=extract_scope_from_www_auth(original_401_response), - ) - credentials = await protocol.authenticate(context) - to_store = _credentials_to_storage(credentials) - await self.storage.set_tokens(to_store) -<<<<<<< HEAD try: if selected_id == "oauth2": # OAuth: drive shared generator (single client, yield) @@ -624,13 +439,6 @@ async def async_auth_flow( "Protocol %s authentication failed: %s", selected_id, e ) continue -======= - break - except Exception as e: - last_auth_error = e - logger.debug("Protocol %s authentication failed: %s", selected_id, e) - continue ->>>>>>> 69d2d1d (refactor(auth): reduce async_auth_flow complexity, remove noqa) credentials = await self._get_credentials() if credentials and self._is_credentials_valid(credentials): From 24bd45bc896ddc44a308c157320a44cf8bb9d2a4 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 10:00:30 +0800 Subject: [PATCH 36/64] fix(auth): pad EC P-256 JWK coordinates to fixed 32 bytes Also sync grant_types_supported test assertions with 6cf977b. --- src/mcp/client/auth/dpop.py | 22 ++++++++++++++----- tests/client/test_auth.py | 2 +- .../mcpserver/auth/test_auth_integration.py | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/mcp/client/auth/dpop.py b/src/mcp/client/auth/dpop.py index 807e35aa5..5b9a2e26d 100644 --- a/src/mcp/client/auth/dpop.py +++ b/src/mcp/client/auth/dpop.py @@ -26,9 +26,19 @@ _RSA_PUBLIC_EXPONENT = 65537 -def _int_to_base64url(num: int) -> str: - """Encode integer to base64url without padding.""" - size = (num.bit_length() + _BITS_PER_BYTE - 1) // _BITS_PER_BYTE +def _int_to_base64url(num: int, *, fixed_length: int | None = None) -> str: + """Encode integer to base64url without padding. + + Args: + num: Non-negative integer to encode. + fixed_length: If set, pad the big-endian representation to exactly + this many bytes. Required for EC coordinates where RFC 7518 §6.2.1 + mandates a fixed octet length (e.g. 32 for P-256). + """ + if fixed_length is not None: + size = fixed_length + else: + size = (num.bit_length() + _BITS_PER_BYTE - 1) // _BITS_PER_BYTE data = num.to_bytes(size, "big") return base64.urlsafe_b64encode(data).decode().rstrip("=") @@ -107,11 +117,13 @@ def _key_to_jwk(key: EllipticCurvePrivateKey | RSAPrivateKey) -> dict[str, Any]: if isinstance(key, EllipticCurvePrivateKey): pub = key.public_key() nums = pub.public_numbers() + # P-256 coordinates must be exactly 32 bytes per RFC 7518 §6.2.1.2 + ec_coord_length = 32 return { "kty": "EC", "crv": "P-256", - "x": _int_to_base64url(nums.x), - "y": _int_to_base64url(nums.y), + "x": _int_to_base64url(nums.x, fixed_length=ec_coord_length), + "y": _int_to_base64url(nums.y, fixed_length=ec_coord_length), } # key is RSAPrivateKey (union type) pub = key.public_key() diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index a2d256898..750347b6d 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1346,7 +1346,7 @@ def test_build_metadata( "token_endpoint": Is(token_endpoint), "registration_endpoint": Is(registration_endpoint), "scopes_supported": ["read", "write", "admin"], - "grant_types_supported": ["authorization_code", "refresh_token"], + "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "service_documentation": Is(service_documentation_url), "revocation_endpoint": Is(revocation_endpoint), diff --git a/tests/server/mcpserver/auth/test_auth_integration.py b/tests/server/mcpserver/auth/test_auth_integration.py index a78a86cf0..ebd5ccdf1 100644 --- a/tests/server/mcpserver/auth/test_auth_integration.py +++ b/tests/server/mcpserver/auth/test_auth_integration.py @@ -322,6 +322,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): assert metadata["grant_types_supported"] == [ "authorization_code", "refresh_token", + "client_credentials", ] assert metadata["service_documentation"] == "https://docs.example.com/" From 5cabe1957d4eced1a7674fc06cab4be46e7bb68b Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 10:45:57 +0800 Subject: [PATCH 37/64] mcp supporting multi-authentication schemas DD --- src/mcp/client/auth/auth-analysis.md | 2915 +++++++++++++++++ .../auth/multi-protocol-refactoring-plan.md | 1313 ++++++++ 2 files changed, 4228 insertions(+) create mode 100644 src/mcp/client/auth/auth-analysis.md create mode 100644 src/mcp/client/auth/multi-protocol-refactoring-plan.md diff --git a/src/mcp/client/auth/auth-analysis.md b/src/mcp/client/auth/auth-analysis.md new file mode 100644 index 000000000..e78fdcba9 --- /dev/null +++ b/src/mcp/client/auth/auth-analysis.md @@ -0,0 +1,2915 @@ +# MCP 客户端鉴权流程分析 + +本文档详细说明 MCP Python SDK 支持的 OAuth 2.0 鉴权协议、授权流程类型、客户端认证方法以及完整的鉴权流程。 + +## 一、支持的鉴权协议 + +MCP Python SDK 基于 **OAuth 2.0** 实现鉴权,支持以下协议和标准: + +### 1. 核心协议 + +- **OAuth 2.0** (RFC 6749) - 主要鉴权协议框架 +- **PKCE** (RFC 7636) - 授权码流程的安全扩展,使用 S256 方法 +- **RFC 8414** - OAuth 2.0 授权服务器元数据发现 +- **RFC 9728** - OAuth 2.0 受保护资源元数据(PRM) +- **RFC 8707** - 资源参数扩展,用于多资源场景 +- **RFC 7523** - JWT Bearer Token 授权(部分支持,主要用于客户端认证) +- **RFC 7591** - OAuth 2.0 动态客户端注册(DCR) + +### 2. 元数据发现机制 + +SDK 实现了完整的元数据发现流程,支持以下发现机制: + +- **受保护资源元数据发现** (RFC 9728) + - 从 `WWW-Authenticate` 头提取 `resource_metadata` 参数 + - 回退到路径感知的 well-known URI: `/.well-known/oauth-protected-resource/{path}` + - 回退到根路径的 well-known URI: `/.well-known/oauth-protected-resource` + +- **授权服务器元数据发现** (RFC 8414) + - 路径感知的 OAuth 发现: `/.well-known/oauth-authorization-server{path}` + - OIDC 发现端点回退: `/.well-known/openid-configuration{path}` 或 `{path}/.well-known/openid-configuration` + - 根路径回退: `/.well-known/oauth-authorization-server` 或 `/.well-known/openid-configuration` + +## 二、支持的授权流程(Grant Types) + +### 2.1 Authorization Code Flow(授权码流程) + +**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) + +**特点**: +- ✅ 使用 PKCE (S256) 增强安全性,防止授权码拦截攻击 +- ✅ 支持动态客户端注册(DCR) +- ✅ 支持 URL-based Client ID (CIMD) +- ✅ 自动令牌刷新 +- ✅ 支持权限升级(insufficient_scope 错误处理) + +**流程步骤**: +1. 生成 PKCE 参数(code_verifier 和 code_challenge) +2. 构建授权请求 URL,包含 state 参数用于防 CSRF +3. 通过 `redirect_handler` 重定向用户到授权端点 +4. 通过 `callback_handler` 接收授权码和 state +5. 使用授权码和 code_verifier 交换访问令牌 + +**代码示例**: +```python +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientMetadata +from pydantic import AnyUrl + +client_metadata = OAuthClientMetadata( + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + grant_types=["authorization_code"], + scope="read write" +) + +provider = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=client_metadata, + storage=token_storage, + redirect_handler=handle_redirect, + callback_handler=handle_callback +) +``` + +### 2.2 Client Credentials Flow(客户端凭证流程) + +**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) + +**提供者类**: + +#### ClientCredentialsOAuthProvider +使用传统的 `client_id` + `client_secret` 进行认证。 + +**支持的认证方法**: +- `client_secret_basic` - HTTP Basic Authentication(默认) +- `client_secret_post` - POST 表单认证 + +**代码示例**: +```python +from mcp.client.auth.extensions import ClientCredentialsOAuthProvider + +provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + token_endpoint_auth_method="client_secret_basic", + scopes="read write" +) +``` + +#### PrivateKeyJWTOAuthProvider +使用 `private_key_jwt` 认证方法,通过 JWT 断言进行客户端认证(RFC 7523 Section 2.2)。 + +**使用场景**: +1. **预构建 JWT**(如 workload identity federation) + ```python + from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, static_assertion_provider + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(prebuilt_jwt) + ) + ``` + +2. **SDK 签名 JWT**(用于测试或简单部署) + ```python + from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, SignedJWTParameters + + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + signing_algorithm="RS256" + ) + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider() + ) + ``` + +3. **Workload Identity Federation** + ```python + async def get_workload_identity_token(audience: str) -> str: + # 从身份提供者获取 JWT + return await fetch_token_from_identity_provider(audience=audience) + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=token_storage, + client_id="my-client-id", + assertion_provider=get_workload_identity_token + ) + ``` + +### 2.3 Refresh Token Flow(刷新令牌流程) + +**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `_refresh_token()` 方法 + +**特点**: +- ✅ 自动检测令牌过期 +- ✅ 在令牌过期前自动刷新 +- ✅ 刷新失败时自动触发完整授权流程 +- ✅ 支持 RFC 8707 资源参数 + +**刷新条件**: +- 当前令牌无效或已过期 +- 存在有效的 refresh_token +- 存在客户端信息(client_info) + +### 2.4 JWT Bearer Grant(已弃用) + +**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) 的 `RFC7523OAuthClientProvider` + +**状态**: ⚠️ 已标记为 deprecated + +**建议**: 使用 `ClientCredentialsOAuthProvider` 或 `PrivateKeyJWTOAuthProvider` 替代 + +## 三、支持的客户端认证方法(Token Endpoint Auth Methods) + +### 3.1 client_secret_basic + +**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `prepare_token_auth()` 方法 + +**认证方式**: HTTP Basic Authentication + +**实现细节**: +- 将 `client_id:client_secret` 进行 URL 编码 +- 使用 Base64 编码后放入 `Authorization` 头 +- 格式: `Authorization: Basic {base64(client_id:client_secret)}` +- 不在请求体中包含 `client_secret` + +**适用场景**: 标准的客户端凭证认证,安全性较高 + +### 3.2 client_secret_post + +**实现位置**: 同上 + +**认证方式**: POST 表单认证 + +**实现细节**: +- 在 POST 请求体的表单数据中包含 `client_secret` +- 同时包含 `client_id` 参数 +- 不使用 `Authorization` 头 + +**适用场景**: 某些不支持 HTTP Basic Auth 的服务器 + +### 3.3 private_key_jwt + +**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) + +**认证方式**: JWT 断言认证(RFC 7523 Section 2.2) + +**实现细节**: +- 创建 JWT 断言,包含以下声明: + - `iss` (issuer): 客户端 ID + - `sub` (subject): 客户端 ID + - `aud` (audience): 授权服务器的 issuer 标识符 + - `exp` (expiration): 过期时间 + - `iat` (issued at): 签发时间 + - `jti` (JWT ID): 唯一标识符 +- 使用私钥签名 JWT +- 在请求体中包含: + - `client_assertion`: JWT 断言 + - `client_assertion_type`: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` + +**适用场景**: +- Workload identity federation +- 无共享密钥的场景 +- 需要更高安全性的企业环境 + +### 3.4 none + +**实现位置**: [`src/mcp/client/auth/utils.py`](src/mcp/client/auth/utils.py) 的 `create_client_info_from_metadata_url()` + +**认证方式**: 无客户端认证 + +**实现细节**: +- 不发送任何客户端凭证 +- 仅使用 URL-based Client ID (CIMD) +- `client_id` 为 HTTPS URL + +**适用场景**: URL-based Client ID (CIMD),当服务器支持 `client_id_metadata_document_supported=true` 时 + +## 四、完整鉴权流程 + +### 4.1 流程概述 + +MCP 客户端鉴权流程在收到 `401 Unauthorized` 响应时自动触发,遵循以下步骤: + +```mermaid +flowchart TD + Start([客户端发起请求]) --> CheckToken{检查存储的令牌} + CheckToken -->|有效| AddHeader[添加 Authorization 头] + CheckToken -->|无效但可刷新| RefreshToken[刷新令牌] + CheckToken -->|无效且不可刷新| SendRequest[发送请求] + + RefreshToken --> RefreshSuccess{刷新成功?} + RefreshSuccess -->|是| AddHeader + RefreshSuccess -->|否| SendRequest + + AddHeader --> SendRequest + SendRequest --> ReceiveResponse[接收响应] + + ReceiveResponse --> CheckStatus{检查状态码} + CheckStatus -->|200| Success([成功]) + CheckStatus -->|401| StartOAuth[启动 OAuth 流程] + CheckStatus -->|403| CheckScope{检查错误类型} + + CheckScope -->|insufficient_scope| StepUpAuth[权限升级授权] + CheckScope -->|其他| Error([错误]) + + StartOAuth --> DiscoverPRM[发现受保护资源元数据] + DiscoverPRM --> DiscoverOASM[发现 OAuth 授权服务器元数据] + DiscoverOASM --> SelectScope[选择 Scope] + SelectScope --> RegisterClient{客户端已注册?} + + RegisterClient -->|否| CheckCIMD{支持 CIMD?} + CheckCIMD -->|是| UseCIMD[使用 URL-based Client ID] + CheckCIMD -->|否| DCR[动态客户端注册] + RegisterClient -->|是| AuthFlow + + UseCIMD --> AuthFlow + DCR --> AuthFlow + + AuthFlow --> GrantType{授权流程类型} + GrantType -->|authorization_code| AuthCodeFlow[授权码流程 + PKCE] + GrantType -->|client_credentials| ClientCredFlow[客户端凭证流程] + + AuthCodeFlow --> ExchangeToken[交换令牌] + ClientCredFlow --> ExchangeToken + + ExchangeToken --> StoreToken[存储令牌] + StoreToken --> RetryRequest[重试原始请求] + RetryRequest --> Success + + StepUpAuth --> AuthCodeFlow +``` + +### 4.2 详细流程步骤 + +#### 阶段 1: 元数据发现 + +1. **提取受保护资源元数据 URL** + - 从 `WWW-Authenticate` 头提取 `resource_metadata` 参数 + - 如果不存在,使用回退 URL + +2. **发现受保护资源元数据(PRM)** + - 按照优先级尝试以下 URL: + 1. `WWW-Authenticate` 头中的 `resource_metadata` URL + 2. `/.well-known/oauth-protected-resource{path}`(路径感知) + 3. `/.well-known/oauth-protected-resource`(根路径) + - 解析 JSON 响应,获取 `authorization_servers` 列表 + +3. **发现 OAuth 授权服务器元数据(OASM)** + - 从 PRM 获取授权服务器 URL + - 按照优先级尝试以下发现端点: + 1. `/.well-known/oauth-authorization-server{path}` + 2. `/.well-known/openid-configuration{path}` + 3. `{path}/.well-known/openid-configuration` + 4. `/.well-known/oauth-authorization-server` + 5. `/.well-known/openid-configuration` + +#### 阶段 2: Scope 选择 + +根据 MCP 规范的 Scope 选择策略,按以下优先级选择 scope: + +1. **优先级 1**: `WWW-Authenticate` 头中的 `scope` 参数 +2. **优先级 2**: PRM 的 `scopes_supported` 字段(所有 scope 用空格连接) +3. **优先级 3**: OASM 的 `scopes_supported` 字段 +4. **优先级 4**: 省略 scope 参数 + +#### 阶段 3: 客户端注册 + +1. **检查是否已注册** + - 从存储中加载客户端信息 + - 如果存在且有效,跳过注册步骤 + +2. **URL-based Client ID (CIMD)** + - 检查服务器是否支持:`client_id_metadata_document_supported == true` + - 检查是否提供了有效的 `client_metadata_url`(必须是 HTTPS URL 且路径不为根路径) + - 如果满足条件,使用 URL 作为 `client_id`,`token_endpoint_auth_method="none"` + +3. **动态客户端注册(DCR)** + - 如果 OASM 包含 `registration_endpoint`,执行 DCR + - 发送客户端元数据到注册端点 + - 接收并存储客户端信息(`client_id`、`client_secret` 等) + +#### 阶段 4: 授权流程 + +根据 `grant_types` 选择相应的授权流程: + +**Authorization Code Flow**: +1. 生成 PKCE 参数(code_verifier 和 code_challenge) +2. 生成随机 state 参数 +3. 构建授权 URL,包含: + - `response_type=code` + - `client_id` + - `redirect_uri` + - `state` + - `code_challenge` 和 `code_challenge_method=S256` + - `scope`(如果已选择) + - `resource`(如果协议版本 >= 2025-06-18 或存在 PRM) +4. 通过 `redirect_handler` 重定向用户 +5. 通过 `callback_handler` 接收授权码和 state +6. 验证 state 参数 + +**Client Credentials Flow**: +- 直接进入令牌交换阶段,无需用户交互 + +#### 阶段 5: 令牌交换 + +1. **构建令牌请求** + - 根据授权流程类型设置 `grant_type` + - 对于 authorization_code: 包含 `code`、`redirect_uri`、`code_verifier` + - 对于 client_credentials: 仅包含 `grant_type` + - 包含 `scope`(如果已选择) + - 包含 `resource`(如果满足条件) + +2. **客户端认证** + - 根据 `token_endpoint_auth_method` 准备认证信息: + - `client_secret_basic`: 在 `Authorization` 头中添加 Basic 认证 + - `client_secret_post`: 在请求体中添加 `client_secret` + - `private_key_jwt`: 生成并添加 JWT 断言 + - `none`: 不添加认证信息 + +3. **发送令牌请求** + - POST 请求到 `token_endpoint` + - 解析响应,获取 `access_token`、`refresh_token`、`expires_in` 等 + +4. **存储令牌** + - 计算令牌过期时间 + - 存储到 `TokenStorage` + - 更新上下文中的令牌信息 + +#### 阶段 6: 重试请求 + +- 使用新获取的访问令牌添加 `Authorization: Bearer {token}` 头 +- 重试原始请求 + +### 4.3 权限升级流程(403 insufficient_scope) + +当收到 `403 Forbidden` 且错误类型为 `insufficient_scope` 时: + +1. 从 `WWW-Authenticate` 头提取新的 scope 要求 +2. 更新客户端元数据中的 scope +3. 重新执行授权流程(通常是 Authorization Code Flow) +4. 使用新的 scope 交换令牌 +5. 重试原始请求 + +### 4.4 自动令牌刷新 + +在每次请求前,SDK 会: + +1. 检查当前令牌是否有效 +2. 如果无效但存在 `refresh_token`,自动刷新 +3. 刷新失败时,清除令牌并触发完整授权流程 + +## 五、特殊功能 + +### 5.1 动态客户端注册(DCR) + +**标准**: RFC 7591 + +**实现**: 自动检测服务器是否支持 DCR(通过检查 `registration_endpoint`),并执行注册 + +**优势**: +- 无需预先配置客户端凭证 +- 简化客户端部署 +- 支持临时客户端 + +**限制**: +- 服务器必须支持 DCR +- 需要提供完整的客户端元数据 + +### 5.2 URL-based Client ID (CIMD) + +**条件**: 服务器需支持 `client_id_metadata_document_supported=true` + +**优势**: +- 无需动态注册 +- 使用 HTTPS URL 作为 `client_id` +- 无需共享密钥(`token_endpoint_auth_method="none"`) +- 支持客户端元数据的去中心化管理 + +**要求**: +- `client_metadata_url` 必须是有效的 HTTPS URL +- URL 路径不能是根路径(`/`) + +### 5.3 元数据发现回退机制 + +**受保护资源元数据** (SEP-985): +- 支持多个回退 URL,提高兼容性 +- 自动尝试下一个 URL 如果当前 URL 返回 404 + +**授权服务器元数据**: +- 支持 OIDC 发现端点回退 +- 兼容仅支持 OIDC 发现的服务器 + +### 5.4 Scope 选择策略 + +**优先级顺序**: +1. `WWW-Authenticate` 头中的 `scope` 参数(最高优先级) +2. PRM 的 `scopes_supported` 字段 +3. OASM 的 `scopes_supported` 字段 +4. 省略 scope 参数(最低优先级) + +**实现位置**: [`src/mcp/client/auth/utils.py`](src/mcp/client/auth/utils.py) 的 `get_client_metadata_scopes()` 函数 + +### 5.5 资源参数支持(RFC 8707) + +**条件**: +- 协议版本 >= `2025-06-18`,或 +- 存在受保护资源元数据(PRM) + +**用途**: 在多资源场景中指定目标资源 + +**实现**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `should_include_resource_param()` 方法 + +### 5.6 PKCE 支持 + +**标准**: RFC 7636 + +**方法**: S256 (SHA256) + +**实现**: +- 自动生成 128 字符的 `code_verifier` +- 使用 SHA256 哈希并 Base64 URL 编码生成 `code_challenge` +- 在授权请求中包含 `code_challenge` 和 `code_challenge_method=S256` +- 在令牌交换时包含 `code_verifier` + +**优势**: 防止授权码拦截攻击,特别适用于公共客户端 + +## 六、关键组件 + +### 6.1 OAuthClientProvider + +**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) + +**职责**: +- 实现 `httpx.Auth` 协议 +- 管理完整的 OAuth 流程 +- 自动处理令牌刷新 +- 集成到 HTTPX 请求流程 + +**主要方法**: +- `async_auth_flow()`: HTTPX 认证流程入口 +- `_perform_authorization()`: 执行授权流程 +- `_refresh_token()`: 刷新访问令牌 +- `_initialize()`: 初始化,加载存储的令牌和客户端信息 + +### 6.2 OAuthContext + +**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) + +**职责**: +- 管理 OAuth 流程的上下文和状态 +- 存储发现的元数据 +- 管理令牌生命周期 +- 提供工具方法(如资源 URL 计算、令牌验证等) + +**关键属性**: +- `server_url`: MCP 服务器 URL +- `client_metadata`: 客户端元数据 +- `oauth_metadata`: OAuth 授权服务器元数据 +- `protected_resource_metadata`: 受保护资源元数据 +- `current_tokens`: 当前访问令牌 +- `client_info`: 客户端信息(client_id、client_secret 等) + +### 6.3 TokenStorage + +**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) + +**协议定义**: +```python +class TokenStorage(Protocol): + async def get_tokens(self) -> OAuthToken | None + async def set_tokens(self, tokens: OAuthToken) -> None + async def get_client_info(self) -> OAuthClientInformationFull | None + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None +``` + +**职责**: 提供令牌和客户端信息的持久化存储接口 + +**实现**: 用户需要实现此协议,可以使用内存、文件、数据库等存储方式 + +### 6.4 PKCEParameters + +**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) + +**职责**: +- 生成 PKCE 参数 +- 验证参数格式 + +**生成方法**: +- `generate()`: 生成新的 PKCE 参数对 + +## 七、相关文件 + +### 核心实现 + +- **`src/mcp/client/auth/oauth2.py`** - 主要 OAuth 实现 + - `OAuthClientProvider`: 基础 OAuth 提供者 + - `OAuthContext`: OAuth 流程上下文 + - `PKCEParameters`: PKCE 参数管理 + - `TokenStorage`: 令牌存储协议 + +- **`src/mcp/client/auth/utils.py`** - 工具函数和元数据发现 + - 元数据发现 URL 构建 + - Scope 选择策略 + - 客户端注册请求构建 + - WWW-Authenticate 头解析 + +- **`src/mcp/client/auth/extensions/client_credentials.py`** - 客户端凭证扩展 + - `ClientCredentialsOAuthProvider`: 客户端凭证提供者 + - `PrivateKeyJWTOAuthProvider`: 私钥 JWT 认证提供者 + - `SignedJWTParameters`: JWT 签名参数 + - `RFC7523OAuthClientProvider`: JWT Bearer Grant(已弃用) + +### 数据模型 + +- **`src/mcp/shared/auth.py`** - OAuth 数据模型定义 + - `OAuthToken`: 访问令牌模型 + - `OAuthClientMetadata`: 客户端元数据(RFC 7591) + - `OAuthClientInformationFull`: 完整客户端信息 + - `OAuthMetadata`: 授权服务器元数据(RFC 8414) + - `ProtectedResourceMetadata`: 受保护资源元数据(RFC 9728) + +### 异常处理 + +- **`src/mcp/client/auth/exceptions.py`** - 鉴权相关异常 + - `OAuthFlowError`: OAuth 流程错误基类 + - `OAuthTokenError`: 令牌操作错误 + - `OAuthRegistrationError`: 客户端注册错误 + +## 八、使用示例 + +### 8.1 基本授权码流程 + +```python +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"请访问: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + # 在实际应用中,这应该从回调 URL 中提取 + auth_code = input("请输入授权码: ") + state = input("请输入 state (可选): ") or None + return auth_code, state + + +async def main(): + storage = InMemoryTokenStorage() + + client_metadata = OAuthClientMetadata( + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + grant_types=["authorization_code"], + scope="read write" + ) + + provider = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=client_metadata, + storage=storage, + redirect_handler=handle_redirect, + callback_handler=handle_callback + ) + + async with httpx.AsyncClient(auth=provider) as client: + response = await client.get("https://api.example.com/api/data") + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 8.2 客户端凭证流程 + +```python +from mcp.client.auth.extensions import ClientCredentialsOAuthProvider + +storage = InMemoryTokenStorage() + +provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=storage, + client_id="my-client-id", + client_secret="my-client-secret", + token_endpoint_auth_method="client_secret_basic", + scopes="read write" +) + +async with httpx.AsyncClient(auth=provider) as client: + response = await client.get("https://api.example.com/api/data") +``` + +### 8.3 私钥 JWT 认证 + +```python +from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, SignedJWTParameters + +# 使用 SDK 签名 JWT +jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + signing_algorithm="RS256" +) + +provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider() +) +``` + +## 九、最佳实践 + +1. **令牌存储**: 使用安全的持久化存储(如加密的数据库或文件系统) +2. **错误处理**: 实现适当的错误处理和重试逻辑 +3. **安全性**: + - 始终使用 HTTPS + - 保护 client_secret 和私钥 + - 使用 PKCE 增强授权码流程安全性 +4. **令牌刷新**: 依赖 SDK 的自动刷新机制,但实现适当的错误处理 +5. **Scope 管理**: 仅请求必要的 scope,遵循最小权限原则 +6. **元数据缓存**: 考虑缓存元数据发现的结果以提高性能 + +## 十、故障排查 + +### 常见问题 + +1. **401 Unauthorized** + - 检查令牌是否过期 + - 验证客户端凭证是否正确 + - 确认 scope 是否足够 + +2. **403 Forbidden (insufficient_scope)** + - 检查请求的 scope + - 使用权限升级流程获取新令牌 + +3. **元数据发现失败** + - 检查服务器 URL 是否正确 + - 验证服务器是否支持 OAuth 2.0 + - 检查网络连接 + +4. **客户端注册失败** + - 验证客户端元数据是否完整 + - 检查服务器是否支持 DCR + - 确认 redirect_uris 格式正确 + +## 十一、OAuth 协议特性与 MCP 必需内容分析 + +本文档详细说明了 MCP Python SDK 的鉴权实现。为了帮助理解实现细节,本节将文档中的内容分为两类:**OAuth 协议自身特性**和**MCP 授权必需内容**。 + +### 11.1 OAuth 协议自身特性 + +这些是 OAuth 2.0/2.1 标准规范定义的内容,不特定于 MCP: + +#### 11.1.1 核心协议标准 +- **OAuth 2.0 (RFC 6749)** - 核心授权框架 +- **PKCE (RFC 7636)** - 授权码流程的安全扩展(OAuth 2.1 要求) +- **RFC 8414** - OAuth 2.0 授权服务器元数据发现(标准 OAuth 发现机制) +- **RFC 8707** - 资源参数扩展(多资源场景) +- **RFC 7523** - JWT Bearer Token 授权(客户端认证方法) +- **RFC 7591** - OAuth 2.0 动态客户端注册(DCR) + +#### 11.1.2 授权流程类型 +- **Authorization Code Flow(授权码流程)** - OAuth 2.0 标准流程 +- **Client Credentials Flow(客户端凭证流程)** - OAuth 2.0 标准流程 +- **Refresh Token Flow(刷新令牌流程)** - OAuth 2.0 标准流程 +- **JWT Bearer Grant** - RFC 7523 定义的流程(已弃用) + +#### 11.1.3 客户端认证方法 +- **client_secret_basic** - HTTP Basic Authentication(RFC 6749) +- **client_secret_post** - POST 表单认证(RFC 6749) +- **private_key_jwt** - JWT 断言认证(RFC 7523 Section 2.2) +- **none** - 无客户端认证(用于公共客户端) + +#### 11.1.4 令牌管理 +- **令牌交换** - 使用授权码/客户端凭证交换访问令牌(OAuth 标准) +- **令牌刷新** - 使用 refresh_token 刷新访问令牌(OAuth 标准) +- **令牌过期检测** - 基于 `expires_in` 字段(OAuth 标准) + +#### 11.1.5 错误处理 +- **insufficient_scope 错误** - OAuth 2.0 标准错误类型(RFC 6750) +- **权限升级流程** - 基于 insufficient_scope 错误重新授权(OAuth 标准实践) + +#### 11.1.6 元数据发现(通用 OAuth) +- **授权服务器元数据发现** - RFC 8414 定义的标准发现机制 +- **OIDC 发现端点回退** - OpenID Connect Discovery 1.0 兼容性 + +### 11.2 MCP 授权必需内容 + +这些是 MCP 授权规范特定的要求,虽然可能基于 OAuth 标准,但 MCP 有特定的实现要求: + +#### 11.2.1 受保护资源元数据(RFC 9728) +- **RFC 9728 PRM 实现** - 虽然是 OAuth 标准,但 MCP **MUST** 实现 +- **从 WWW-Authenticate 头提取 `resource_metadata` 参数** - MCP 特定的发现机制 +- **路径感知的 well-known URI** - MCP 特定的回退机制: + - `/.well-known/oauth-protected-resource{path}`(路径感知) + - `/.well-known/oauth-protected-resource`(根路径回退) + +#### 11.2.2 发现机制要求 +- **MUST 支持两种发现机制**: + 1. WWW-Authenticate 头中的 `resource_metadata` 参数 + 2. Well-known URI 回退机制 +- **MUST 优先使用 WWW-Authenticate 头** - MCP 特定的优先级规则 +- **MUST 能够解析 WWW-Authenticate 头并响应 401 错误** - MCP 特定的集成方式 + +#### 11.2.3 Scope 选择策略 +- **优先级顺序**(MCP 特定): + 1. `WWW-Authenticate` 头中的 `scope` 参数(最高优先级) + 2. PRM 的 `scopes_supported` 字段 + 3. OASM 的 `scopes_supported` 字段 + 4. 省略 scope 参数(最低优先级) +- **从 WWW-Authenticate 头提取 scope** - MCP 特定的行为 + +#### 11.2.4 自动触发机制 +- **收到 401 Unauthorized 时自动触发授权流程** - MCP 特定的集成方式 +- **自动重试原始请求** - MCP 特定的行为 + +#### 11.2.5 资源参数支持 +- **条件判断** - MCP 特定的逻辑: + - 协议版本 >= `2025-06-18`,或 + - 存在受保护资源元数据(PRM) +- **在授权请求中包含 `resource` 参数** - 基于 RFC 8707,但 MCP 有特定的触发条件 + +#### 11.2.6 URL-based Client ID (CIMD) +- **OAuth Client ID Metadata Documents** - 虽然是 OAuth 标准扩展,但 MCP **SHOULD** 支持 +- **CIMD 条件检查** - MCP 特定的实现逻辑: + - 检查 `client_id_metadata_document_supported == true` + - 验证 `client_metadata_url` 必须是 HTTPS URL 且路径不为根路径 + +#### 11.2.7 路径感知的元数据发现 +- **授权服务器元数据发现的路径感知机制** - MCP 特定的回退策略: + 1. `/.well-known/oauth-authorization-server{path}`(路径插入) + 2. `/.well-known/openid-configuration{path}`(路径插入) + 3. `{path}/.well-known/openid-configuration`(路径后置) + 4. 根路径回退 + +#### 11.2.8 服务器端要求 +- **MCP 服务器 MUST 实现 RFC 9728 PRM** - MCP 特定的强制要求 +- **MCP 服务器 MUST 在 PRM 中包含 `authorization_servers` 字段** - MCP 特定的数据结构要求 +- **MCP 服务器 SHOULD 在 WWW-Authenticate 头中包含 `scope` 参数** - MCP 特定的建议 + +### 11.3 混合特性(OAuth 标准 + MCP 特定要求) + +某些内容既是 OAuth 标准,但 MCP 有特定的强制要求: + +#### 11.3.1 PKCE +- **OAuth 标准**:RFC 7636 定义的可选扩展 +- **MCP 要求**:**MUST** 使用 PKCE(OAuth 2.1 要求,MCP 遵循 OAuth 2.1) +- **MCP 特定**:**MUST** 使用 S256 方法,**MUST** 验证授权服务器支持 + +#### 11.3.2 动态客户端注册(DCR) +- **OAuth 标准**:RFC 7591 定义的可选功能 +- **MCP 要求**:**MAY** 支持(可选,但 MCP 客户端应该支持) + +#### 11.3.3 授权服务器元数据发现 +- **OAuth 标准**:RFC 8414 定义的标准机制 +- **MCP 要求**:**MUST** 支持,且必须支持 OIDC 发现端点作为回退 + +### 11.4 总结 + +#### OAuth 协议自身特性(约 60%) +- 核心协议和标准(RFC 6749, 7636, 8414, 8707, 7523, 7591) +- 授权流程类型(授权码、客户端凭证、刷新令牌) +- 客户端认证方法(basic, post, jwt, none) +- 令牌管理和错误处理 +- 通用的元数据发现机制 + +#### MCP 授权必需内容(约 40%) +- RFC 9728 PRM 的强制实现 +- WWW-Authenticate 头解析和 `resource_metadata` 参数提取 +- 路径感知的 well-known URI 发现机制 +- MCP 特定的 Scope 选择策略 +- 自动触发授权流程(401 响应) +- 资源参数的 MCP 特定触发条件 +- CIMD 的 MCP 特定实现逻辑 + +#### 关键区别 +1. **OAuth 标准**:定义了"可以做什么"和"如何做" +2. **MCP 要求**:定义了"必须做什么"和"特定的实现方式" +3. **MCP 扩展**:在 OAuth 标准基础上添加了特定的发现机制、优先级规则和集成方式 + +## 十二、实现新鉴权协议所需功能 + +基于上述分析,如果要实现 MCP 支持一套新的鉴权协议(与 OAuth 不同),需要提供或实现以下功能。这些功能分为三类:**MCP 必需的基础设施**、**协议特定的实现**和**可选的增强功能**。 + +### 12.1 MCP 必需的基础设施(必须实现) + +这些是 MCP 授权规范要求的基础设施,所有协议都需要支持: + +#### 12.1.1 受保护资源元数据(PRM)支持 +- **实现 RFC 9728 PRM 端点** + - 提供 `/.well-known/oauth-protected-resource` 端点(或路径感知版本) + - 返回包含协议信息的 PRM JSON 文档 + - 支持从 `WWW-Authenticate` 头中的 `resource_metadata` 参数指向 PRM URL + +- **扩展 PRM 数据结构**(使用 RFC 9728 扩展机制) + - 在 PRM 中添加 `mcp_auth_protocols` 字段,声明支持的协议列表 + - 每个协议元数据包含: + - `protocol_id`: 协议标识符(如 "api_key", "mutual_tls") + - `protocol_version`: 协议版本 + - `metadata_url`: 协议特定的元数据发现 URL(可选) + - `endpoints`: 协议端点映射(如 token_endpoint, authorize_endpoint) + - `capabilities`: 协议能力列表 + - `client_auth_methods`: 客户端认证方法(如果适用) + - `additional_params`: 协议特定的额外参数 + +#### 12.1.2 WWW-Authenticate 头支持 +- **服务器端:构建扩展的 WWW-Authenticate 头** + - 在 401 响应中包含协议声明: + ``` + WWW-Authenticate: Bearer error="invalid_token", + resource_metadata="https://...", + auth_protocols="oauth2 api_key mutual_tls", + default_protocol="oauth2", + protocol_preferences="oauth2:1,api_key:2" + ``` + - 支持协议特定的认证方案(如 `ApiKey`, `MutualTLS`) + +- **客户端:解析 WWW-Authenticate 头** + - 提取 `resource_metadata` 参数 + - 解析 `auth_protocols` 字段(如果存在) + - 识别 `default_protocol` 和协议优先级 + - 支持非 Bearer 的认证方案 + +#### 12.1.3 协议发现机制 +- **统一的能力发现端点**(推荐) + - 实现 `/.well-known/authorization_servers` 端点 + - 返回服务器支持的所有授权协议列表和能力信息 + - 客户端首先访问此端点发现可用协议 + +- **协议特定的元数据发现** + - 每个协议可以提供自己的元数据发现端点 + - 客户端根据协议 ID 调用相应的发现逻辑 + - 支持路径感知的 well-known URI(如 `/.well-known/{protocol}-metadata{path}`) + +#### 12.1.4 自动触发机制 +- **客户端:401 响应自动触发授权** + - 检测 401 Unauthorized 响应 + - 自动解析 WWW-Authenticate 头 + - 自动发现协议并启动授权流程 + - 授权成功后自动重试原始请求 + +- **客户端:403 响应权限升级** + - 检测 403 Forbidden 和 insufficient_scope 错误 + - 提取新的权限要求 + - 重新执行授权流程获取新凭证 + +### 12.2 协议特定的实现(每个协议必须实现) + +这些是每个新协议需要实现的核心功能: + +#### 12.2.1 协议抽象接口实现 +```python +class AuthProtocol(Protocol): + """授权协议抽象接口""" + + protocol_id: str # 协议标识符 + protocol_version: str # 协议版本 + + async def discover_metadata( + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + """发现协议特定的元数据""" + pass + + async def authenticate( + self, + context: AuthContext + ) -> AuthCredentials: + """执行协议特定的认证流程""" + pass + + def prepare_request( + self, + request: httpx.Request, + credentials: AuthCredentials + ) -> None: + """为请求添加协议特定的认证信息""" + pass + + async def register_client( + self, + context: AuthContext + ) -> ClientRegistrationResult | None: + """协议特定的客户端注册(如果适用)""" + pass + + def validate_credentials( + self, + credentials: AuthCredentials + ) -> bool: + """验证凭证是否有效(客户端)""" + pass +``` + +#### 12.2.2 元数据发现 +- **实现协议特定的元数据发现逻辑** + - 从协议元数据端点获取配置信息 + - 解析协议特定的能力声明 + - 提取协议端点(如 token_endpoint, authorize_endpoint) + - 缓存元数据以提高性能 + +#### 12.2.3 认证流程执行 +- **实现协议特定的认证步骤** + - OAuth 2.0: 授权码流程 + PKCE + 令牌交换 + - API Key: 直接使用配置的 API Key(可能涉及密钥交换) + - Mutual TLS: 客户端证书验证(在 TLS 握手时) + - 自定义协议: 实现特定的认证步骤序列 + +- **支持协议特定的交互方式** + - 用户交互(如 OAuth 授权码流程) + - 自动认证(如客户端凭证、API Key) + - 混合方式(如需要用户确认的 API Key 激活) + +#### 12.2.4 凭证管理 +- **定义协议特定的凭证数据结构** + ```python + class APIKeyCredentials(AuthCredentials): + protocol_id: str = "api_key" + api_key: str + key_id: str | None = None + expires_at: int | None = None + ``` + +- **实现凭证存储和检索** + - 凭证序列化/反序列化 + - 凭证过期检查 + - 凭证刷新机制(如果协议支持) + +#### 12.2.5 请求认证信息准备 +- **实现协议特定的认证头/参数添加** + - OAuth: `Authorization: Bearer ` + - API Key: `X-API-Key: ` 或 `Authorization: ApiKey ` + - Mutual TLS: 客户端证书(在 TLS 握手时,HTTP 层无需额外头) + - 自定义: 协议特定的头、查询参数或请求体参数 + +#### 12.2.6 服务器端凭证验证 +- **实现协议特定的验证逻辑** + - OAuth: JWT 验证、令牌内省、签名验证 + - API Key: 密钥验证、过期检查、权限检查 + - Mutual TLS: 证书链验证、CN/OU 验证、证书撤销检查 + - 自定义: 协议特定的验证步骤 + +- **实现验证器接口** + ```python + class CredentialVerifier(Protocol): + async def verify( + self, + request: Request + ) -> AuthInfo | None: + """验证请求中的凭证""" + pass + ``` + +### 12.3 可选的增强功能 + +这些功能可以提升协议实现的完整性和用户体验: + +#### 12.3.1 客户端注册机制 +- **如果协议需要客户端注册** + - 实现协议特定的注册流程 + - 支持动态注册(如 OAuth DCR) + - 支持静态配置(如预分配的 API Key) + - 支持 URL-based Client ID(如 OAuth CIMD) + +#### 12.3.2 权限/Scope 模型 +- **如果协议有权限概念** + - 定义协议特定的权限模型 + - 实现权限选择策略(类似 OAuth 的 scope 选择) + - 支持权限升级机制 + - 实现权限验证逻辑 + +#### 12.3.3 凭证刷新机制 +- **如果协议支持凭证刷新** + - 实现自动刷新逻辑 + - 在凭证过期前自动刷新 + - 刷新失败时的降级处理 + +#### 12.3.4 错误处理 +- **实现协议特定的错误处理** + - 定义协议错误代码 + - 实现错误响应构建(服务器端) + - 实现错误解析和处理(客户端) + - 支持协议切换(如果当前协议失败,尝试其他协议) + +#### 12.3.5 协议能力声明 +- **声明协议支持的能力** + - 凭证刷新 + - 权限升级 + - 客户端注册 + - 元数据发现 + - 其他协议特定的能力 + +### 12.4 实现检查清单 + +实现新协议时,可以使用以下检查清单确保完整性: + +#### 数据模型层 +- [ ] 定义协议元数据模型(`AuthProtocolMetadata`) +- [ ] 定义协议凭证模型(继承 `AuthCredentials`) +- [ ] 扩展 PRM 数据结构支持协议声明 +- [ ] 定义协议特定的错误响应模型(如果适用) + +#### 客户端实现 +- [ ] 实现 `AuthProtocol` 接口 +- [ ] 实现元数据发现逻辑 +- [ ] 实现认证流程执行 +- [ ] 实现请求认证信息准备 +- [ ] 实现凭证验证(客户端) +- [ ] 实现客户端注册(如果适用) +- [ ] 实现权限选择(如果适用) +- [ ] 实现错误处理 +- [ ] 在协议注册表中注册协议 + +#### 服务器实现 +- [ ] 实现 PRM 端点,包含协议声明 +- [ ] 实现协议特定的元数据发现端点(如果适用) +- [ ] 实现统一的能力发现端点(`/.well-known/authorization_servers`) +- [ ] 实现 `CredentialVerifier` 接口 +- [ ] 实现凭证验证逻辑 +- [ ] 实现 WWW-Authenticate 头构建(包含协议声明) +- [ ] 实现错误响应构建 +- [ ] 实现权限验证(如果适用) +- [ ] 在认证中间件中注册协议验证器 + +#### 集成和测试 +- [ ] 实现协议注册机制 +- [ ] 实现协议发现和选择逻辑 +- [ ] 实现协议切换机制(如果第一个协议失败) +- [ ] 编写单元测试 +- [ ] 编写集成测试 +- [ ] 编写文档和使用示例 + +### 12.5 与 OAuth 的区别 + +实现新协议时,需要注意以下与 OAuth 的区别: + +#### 不需要实现的功能(OAuth 特定) +- ❌ 授权码流程(OAuth 特定) +- ❌ PKCE(OAuth 特定) +- ❌ 令牌交换(OAuth 特定) +- ❌ Refresh Token(OAuth 特定) +- ❌ Scope 模型(OAuth 特定,除非新协议也有类似概念) +- ❌ OAuth 客户端认证方法(client_secret_basic 等) + +#### 必须实现的功能(MCP 通用) +- ✅ PRM 支持和协议声明 +- ✅ WWW-Authenticate 头解析/构建 +- ✅ 协议发现机制 +- ✅ 自动触发授权流程(401 响应) +- ✅ 凭证管理和验证 +- ✅ 请求认证信息准备 + +#### 可选实现的功能(协议特定) +- ⚠️ 客户端注册(取决于协议需求) +- ⚠️ 权限模型(取决于协议需求) +- ⚠️ 凭证刷新(取决于协议需求) +- ⚠️ 元数据发现(取决于协议复杂度) + +### 12.6 参考实现 + +可以参考以下现有实现来理解如何实现新协议: + +- **OAuth 2.0 实现**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) +- **客户端凭证扩展**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) +- **多协议设计文档**: [`src/mcp/client/auth/multi-protocol-design.md`](src/mcp/client/auth/multi-protocol-design.md) +- **扩展改造点分析**: [`mcp-auth-extension-analysis.md`](../mcp-auth-extension-analysis.md) + +### 12.7 总结 + +实现 MCP 支持新鉴权协议需要: + +1. **MCP 必需的基础设施**(约 40%):PRM 支持、WWW-Authenticate 头、协议发现、自动触发机制 +2. **协议特定的实现**(约 50%):协议接口实现、认证流程、凭证管理、验证逻辑 +3. **可选的增强功能**(约 10%):客户端注册、权限模型、凭证刷新、错误处理 + +关键是要区分: +- **MCP 通用要求**:所有协议都必须支持 +- **协议特定实现**:每个协议需要实现自己的逻辑 +- **OAuth 特定功能**:不需要在新协议中实现 + +## 十三、现有代码改造点清单 + +基于章节11和12的分析,特别是必须实现的功能清单,以下是扩展支持新鉴权协议需要对现有代码进行的改造点。 + +### 13.1 数据模型层改造 + +#### 13.1.1 扩展 ProtectedResourceMetadata(PRM) + +**文件**: `src/mcp/shared/auth.py` + +**当前状态**: 仅支持 OAuth 2.0 的 `authorization_servers` 字段 + +**改造内容**: +1. **新增协议元数据模型** + ```python + class AuthProtocolMetadata(BaseModel): + """单个授权协议的元数据(MCP 扩展)""" + protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") + protocol_version: str + metadata_url: AnyHttpUrl | None = None + endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) + capabilities: list[str] = Field(default_factory=list) + client_auth_methods: list[str] | None = None + grant_types: list[str] | None = None # OAuth 特定 + scopes_supported: list[str] | None = None # OAuth 特定 + additional_params: dict[str, Any] = Field(default_factory=dict) + ``` + +2. **扩展 ProtectedResourceMetadata** + ```python + class ProtectedResourceMetadata(BaseModel): + # 保持 RFC 9728 必需字段不变(向后兼容) + resource: AnyHttpUrl + authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) + + # ... 现有字段 ... + + # MCP 扩展字段(使用 mcp_ 前缀) + mcp_auth_protocols: list[AuthProtocolMetadata] | None = Field( + None, + description="MCP 扩展:支持的授权协议列表" + ) + mcp_default_auth_protocol: str | None = Field( + None, + description="MCP 扩展:默认推荐的授权协议 ID" + ) + mcp_auth_protocol_preferences: dict[str, int] | None = Field( + None, + description="MCP 扩展:协议优先级映射" + ) + ``` + +3. **向后兼容处理** + - 如果 `mcp_auth_protocols` 为空,自动从 `authorization_servers` 创建 OAuth 2.0 协议元数据 + - 标准 OAuth 客户端可以忽略 `mcp_*` 扩展字段 + +#### 13.1.2 新增通用凭证模型 + +**文件**: `src/mcp/shared/auth.py`(新增) + +**改造内容**: +1. **定义基础凭证接口** + ```python + class AuthCredentials(BaseModel): + """通用凭证基类""" + protocol_id: str + expires_at: int | None = None + + class OAuthCredentials(AuthCredentials): + """OAuth 凭证(现有 OAuthToken 的包装)""" + protocol_id: str = "oauth2" + access_token: str + token_type: Literal["Bearer"] = "Bearer" + refresh_token: str | None = None + scope: str | None = None + ``` + +2. **扩展 TokenStorage 协议** + ```python + class TokenStorage(Protocol): + async def get_tokens(self) -> AuthCredentials | None: ... + async def set_tokens(self, tokens: AuthCredentials) -> None: ... + # ... 现有方法 ... + ``` + +#### 13.1.3 新增协议抽象接口 + +**文件**: `src/mcp/client/auth/protocol.py`(新建) + +**改造内容**: +1. **定义协议抽象接口** + ```python + class AuthProtocol(Protocol): + protocol_id: str + protocol_version: str + + async def discover_metadata(...) -> AuthProtocolMetadata | None: ... + async def authenticate(...) -> AuthCredentials: ... + def prepare_request(...) -> None: ... + async def register_client(...) -> ClientRegistrationResult | None: ... + def validate_credentials(...) -> bool: ... + ``` + +2. **定义服务器端验证器接口** + ```python + class CredentialVerifier(Protocol): + async def verify(self, request: Request) -> AuthInfo | None: ... + ``` + +### 13.2 客户端代码改造 + +#### 13.2.1 WWW-Authenticate 头解析扩展 + +**文件**: `src/mcp/client/auth/utils.py` + +**当前函数**: `extract_field_from_www_auth()`, `extract_resource_metadata_from_www_auth()` + +**改造内容**: +1. **新增协议相关字段提取** + ```python + def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: + """提取 auth_protocols 字段""" + + def extract_default_protocol_from_www_auth(response: Response) -> str | None: + """提取 default_protocol 字段""" + + def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: + """提取 protocol_preferences 字段""" + ``` + +2. **扩展解析逻辑** + - 支持非 Bearer 的认证方案(如 `ApiKey`, `MutualTLS`) + - 解析协议声明和优先级 + +#### 13.2.2 协议发现机制 + +**文件**: `src/mcp/client/auth/utils.py` + +**当前函数**: `build_protected_resource_metadata_discovery_urls()`, `build_oauth_authorization_server_metadata_discovery_urls()` + +**改造内容**: +1. **新增统一能力发现端点支持** + ```python + async def discover_authorization_servers( + resource_url: str, + http_client: httpx.AsyncClient + ) -> list[AuthProtocolMetadata]: + """统一的授权服务器发现流程""" + # 1. 首先访问统一的能力发现端点 + # 2. 根据返回的列表,访问每个协议的元数据端点 + ``` + +2. **新增协议特定的元数据发现** + ```python + async def discover_protocol_metadata( + protocol_id: str, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + """协议特定的元数据发现""" + ``` + +3. **修改现有发现函数** + - `build_oauth_authorization_server_metadata_discovery_urls()` 改为协议特定的发现函数 + - 支持路径感知的协议元数据发现端点 + +#### 13.2.3 协议注册和选择机制 + +**文件**: `src/mcp/client/auth/registry.py`(新建) + +**改造内容**: +1. **实现协议注册表** + ```python + class AuthProtocolRegistry: + _protocols: dict[str, type[AuthProtocol]] = {} + + @classmethod + def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]): ... + + @classmethod + def get_protocol(cls, protocol_id: str) -> type[AuthProtocol] | None: ... + + @classmethod + def select_protocol( + cls, + available_protocols: list[str], + default_protocol: str | None = None, + preferences: dict[str, int] | None = None + ) -> str | None: ... + ``` + +2. **协议选择逻辑** + - 根据优先级、默认协议、客户端支持情况选择协议 + - 支持协议切换(如果第一个协议失败) + +#### 13.2.4 OAuthClientProvider 重构 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前类**: `OAuthClientProvider`, `OAuthContext` + +**改造内容**: +1. **抽象为多协议提供者** + ```python + class MultiProtocolAuthProvider(httpx.Auth): + """多协议认证提供者""" + def __init__( + self, + server_url: str, + protocols: list[AuthProtocol], + storage: TokenStorage, + ... + ): ... + + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, None]: + # 1. 检查存储的凭证 + # 2. 如果无效,触发协议发现和选择 + # 3. 执行选中的协议认证流程 + # 4. 准备请求认证信息 + ``` + +2. **OAuthClientProvider 适配** + - 将 `OAuthClientProvider` 改为 `OAuth2Protocol` 实现 + - 保持现有 API 不变(向后兼容) + - 内部使用 `MultiProtocolAuthProvider` + +3. **OAuthContext 扩展** + - 支持多协议上下文 + - 协议特定的元数据存储 + +#### 13.2.5 请求认证信息准备 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前方法**: `_add_auth_header()`(在 OAuthClientProvider 中) + +**改造内容**: +1. **抽象为协议方法** + - 每个协议实现自己的 `prepare_request()` 方法 + - OAuth: `Authorization: Bearer ` + - API Key: `X-API-Key: ` 或 `Authorization: ApiKey ` + - Mutual TLS: 在 TLS 握手时处理 + +2. **在 MultiProtocolAuthProvider 中调用** + ```python + def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials): + protocol = self.registry.get_protocol(credentials.protocol_id) + protocol.prepare_request(request, credentials) + ``` + +#### 13.2.6 凭证存储扩展 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前协议**: `TokenStorage` + +**改造内容**: +1. **扩展 TokenStorage 协议** + ```python + class TokenStorage(Protocol): + async def get_tokens(self) -> AuthCredentials | None: ... + async def set_tokens(self, tokens: AuthCredentials) -> None: ... + # 保持现有方法以支持向后兼容 + ``` + +2. **凭证序列化/反序列化** + - 支持多种凭证类型的序列化 + - 根据 `protocol_id` 反序列化为正确的类型 + +### 13.3 服务器端代码改造 + +#### 13.3.1 PRM 端点扩展 + +**文件**: `src/mcp/server/auth/routes.py` + +**当前函数**: `create_protected_resource_routes()` + +**改造内容**: +1. **扩展函数签名** + ```python + def create_protected_resource_routes( + resource_url: AnyHttpUrl, + authorization_servers: list[AnyHttpUrl], + scopes_supported: list[str] | None = None, + # 新增参数 + auth_protocols: list[AuthProtocolMetadata] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + ... + ) -> list[Route]: + ``` + +2. **构建扩展的 PRM** + ```python + metadata = ProtectedResourceMetadata( + resource=resource_url, + authorization_servers=authorization_servers, # 保持向后兼容 + scopes_supported=scopes_supported, + mcp_auth_protocols=auth_protocols, # 新增 + mcp_default_auth_protocol=default_protocol, # 新增 + mcp_auth_protocol_preferences=protocol_preferences, # 新增 + ... + ) + ``` + +#### 13.3.2 统一能力发现端点 + +**文件**: `src/mcp/server/auth/routes.py`(新增函数) + +**改造内容**: +1. **新增统一发现端点** + ```python + def create_authorization_servers_discovery_routes( + resource_url: AnyHttpUrl, + auth_protocols: list[AuthProtocolMetadata], + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + ) -> list[Route]: + """创建统一的能力发现端点 /.well-known/authorization_servers""" + ``` + +2. **实现端点处理器** + ```python + class AuthorizationServersDiscoveryHandler: + async def handle(self, request: Request) -> Response: + """返回服务器支持的所有授权协议列表""" + ``` + +#### 13.3.3 WWW-Authenticate 头构建扩展 + +**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` + +**当前方法**: `_send_auth_error()` + +**改造内容**: +1. **扩展错误响应构建** + ```python + async def _send_auth_error( + self, + send: Send, + status_code: int, + error: str, + description: str, + # 新增参数 + resource_metadata_url: AnyHttpUrl | None = None, + auth_protocols: list[str] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + ) -> None: + """构建扩展的 WWW-Authenticate 头""" + parts = [ + f'error="{error}"', + f'error_description="{description}"' + ] + + if resource_metadata_url: + parts.append(f'resource_metadata="{resource_metadata_url}"') + + if auth_protocols: + parts.append(f'auth_protocols="{" ".join(auth_protocols)}"') + + if default_protocol: + parts.append(f'default_protocol="{default_protocol}"') + + if protocol_preferences: + prefs_str = ",".join(f"{proto}:{priority}" + for proto, priority in protocol_preferences.items()) + parts.append(f'protocol_preferences="{prefs_str}"') + + www_auth = f"Bearer {', '.join(parts)}" + ``` + +2. **修改 RequireAuthMiddleware** + - 添加协议信息参数 + - 在 401/403 响应中包含协议声明 + +#### 13.3.4 认证后端重构 + +**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` + +**当前类**: `BearerAuthBackend` + +**改造内容**: +1. **新增多协议认证后端** + ```python + class MultiProtocolAuthBackend(AuthenticationBackend): + """多协议认证后端""" + def __init__( + self, + verifiers: dict[str, CredentialVerifier] + ): + self.verifiers = verifiers + + async def authenticate(self, conn: HTTPConnection): + """尝试所有支持的协议""" + for protocol_id, verifier in self.verifiers.items(): + result = await verifier.verify(conn) + if result: + return result + return None + ``` + +2. **BearerAuthBackend 适配** + - 将 `BearerAuthBackend` 改为 OAuth 特定的验证器 + - 在 `MultiProtocolAuthBackend` 中注册 + +3. **新增协议特定的验证器** + ```python + class APIKeyVerifier(CredentialVerifier): + async def verify(self, request: Request) -> AuthInfo | None: ... + + class MutualTLSVerifier(CredentialVerifier): + async def verify(self, request: Request) -> AuthInfo | None: ... + ``` + +#### 13.3.5 协议特定的元数据端点 + +**文件**: `src/mcp/server/auth/routes.py`(新增函数) + +**改造内容**: +1. **新增协议元数据端点创建函数** + ```python + def create_protocol_metadata_routes( + protocol_id: str, + metadata: AuthProtocolMetadata + ) -> list[Route]: + """创建协议特定的元数据发现端点""" + # 例如: /.well-known/api-key-metadata + ``` + +### 13.4 新增文件和模块 + +#### 13.4.1 协议抽象和接口 + +**新建文件**: `src/mcp/client/auth/protocol.py` +- `AuthProtocol` 协议接口 +- `AuthProtocolMetadata` 模型(或从 shared 导入) +- 协议注册表 + +**新建文件**: `src/mcp/client/auth/registry.py` +- `AuthProtocolRegistry` 类 +- 协议选择逻辑 + +#### 13.4.2 多协议提供者 + +**新建文件**: `src/mcp/client/auth/multi_protocol.py` +- `MultiProtocolAuthProvider` 类 +- 协议发现和选择逻辑 +- 凭证管理 + +#### 13.4.3 OAuth 协议实现 + +**新建文件**: `src/mcp/client/auth/protocols/oauth2.py` +- `OAuth2Protocol` 类(实现 `AuthProtocol`) +- 将现有 `OAuthClientProvider` 逻辑迁移到这里 + +#### 13.4.4 服务器端验证器 + +**新建文件**: `src/mcp/server/auth/verifiers.py` +- `CredentialVerifier` 协议接口 +- `OAuthTokenVerifier`(现有 TokenVerifier 的适配) +- `MultiProtocolAuthBackend` + +**新建文件**: `src/mcp/server/auth/handlers/discovery.py` +- `AuthorizationServersDiscoveryHandler` 类 + +### 13.5 改造优先级和依赖关系 + +#### 高优先级(必须首先实现) +1. **数据模型扩展**(13.1) + - `AuthProtocolMetadata` 模型 + - `ProtectedResourceMetadata` 扩展 + - `AuthCredentials` 基类 + +2. **协议抽象接口**(13.4.1) + - `AuthProtocol` 接口定义 + - `CredentialVerifier` 接口定义 + +3. **WWW-Authenticate 头扩展**(13.2.1, 13.3.3) + - 客户端解析扩展 + - 服务器端构建扩展 + +#### 中优先级(核心功能) +4. **协议发现机制**(13.2.2, 13.3.2) + - 统一能力发现端点 + - 协议特定的元数据发现 + +5. **协议注册和选择**(13.2.3) + - 协议注册表 + - 协议选择逻辑 + +6. **多协议提供者**(13.2.4, 13.4.2) + - `MultiProtocolAuthProvider` + - 协议切换机制 + +7. **认证后端重构**(13.3.4) + - `MultiProtocolAuthBackend` + - 协议特定的验证器 + +#### 低优先级(向后兼容和优化) +8. **OAuth 适配**(13.2.4) + - `OAuth2Protocol` 实现 + - `OAuthClientProvider` 向后兼容包装 + +9. **PRM 端点扩展**(13.3.1) + - 扩展 `create_protected_resource_routes()` + +10. **凭证存储扩展**(13.2.6) + - `TokenStorage` 协议扩展 + - 凭证序列化/反序列化 + +### 13.6 向后兼容策略 + +#### 数据模型兼容 +- 保持 `ProtectedResourceMetadata` 的 `authorization_servers` 字段为必需 +- `mcp_*` 扩展字段为可选 +- 如果 `mcp_auth_protocols` 为空,自动从 `authorization_servers` 创建 OAuth 协议元数据 + +#### API 兼容 +- `OAuthClientProvider` 保持现有 API 不变 +- 内部使用 `OAuth2Protocol` 和 `MultiProtocolAuthProvider` +- 现有代码无需修改即可工作 + +#### 行为兼容 +- 默认行为:如果没有协议声明,使用 OAuth 2.0 +- 现有 OAuth 流程保持不变 +- 新协议作为可选功能添加 + +### 13.7 测试改造点 + +#### 单元测试 +- 数据模型序列化/反序列化测试 +- 协议发现逻辑测试 +- 协议选择逻辑测试 +- WWW-Authenticate 头解析/构建测试 + +#### 集成测试 +- 多协议认证流程测试 +- 协议切换测试 +- 向后兼容性测试 + +### 13.8 总结 + +改造点分为三个层次: + +1. **基础设施层**(必须首先实现): + - 数据模型扩展 + - 协议抽象接口 + - WWW-Authenticate 头扩展 + +2. **核心功能层**(实现多协议支持): + - 协议发现机制 + - 协议注册和选择 + - 多协议提供者和认证后端 + +3. **适配层**(向后兼容): + - OAuth 协议实现 + - 现有 API 的兼容包装 + - 凭证存储扩展 + +**关键原则**: +- 保持向后兼容 +- 渐进式改造 +- 协议抽象和统一接口 +- MCP 通用功能与协议特定实现分离 + +## 十四、OAuth 2.0 DPoP 支持改造点 + +DPoP (Demonstrating Proof-of-Possession, RFC 9449) 是 OAuth 2.0 的安全扩展,用于将访问令牌绑定到客户端的加密密钥。与多协议扩展不同,DPoP 是 OAuth 2.0 协议内部的增强功能,需要在现有 OAuth 实现基础上添加支持。 + +### 14.1 DPoP 概述 + +#### 14.1.1 DPoP 核心概念 +- **密钥对生成**:客户端生成公钥/私钥对用于签名 DPoP 证明 +- **DPoP 证明**:客户端为每个请求创建 DPoP JWT,包含: + - `htm`: HTTP 方法 + - `htu`: HTTP URI + - `jti`: JWT ID(防重放) + - `iat`: 签发时间 + - `ath`: 访问令牌哈希(如果存在) + - `cnf`: 确认信息(包含公钥的 JWK) +- **令牌绑定**:服务器可以将访问令牌绑定到 DPoP 公钥(通过 `cnf.jkt`) +- **验证**:资源服务器验证 DPoP 证明与令牌中的公钥匹配 + +#### 14.1.2 DPoP 与多协议设计的关系 +- DPoP 是 **OAuth 2.0 协议的扩展**,不是独立协议 +- 在 `OAuth2Protocol` 实现中添加 DPoP 支持 +- 不影响多协议架构,只是 OAuth 2.0 实现的一个可选增强 + +### 14.2 数据模型改造 + +#### 14.2.1 扩展 OAuthMetadata + +**文件**: `src/mcp/shared/auth.py` + +**当前状态**: `OAuthMetadata` 类已存在,但缺少 DPoP 相关字段 + +**改造内容**: +1. **添加 DPoP 元数据字段** + ```python + class OAuthMetadata(BaseModel): + # ... 现有字段 ... + + # DPoP 支持字段(RFC 9449) + dpop_signing_alg_values_supported: list[str] | None = Field( + None, + description="支持的 DPoP 签名算法列表(如 ['ES256', 'RS256'])" + ) + ``` + +2. **ProtectedResourceMetadata 已有字段** + - `dpop_signing_alg_values_supported` ✅ 已存在 + - `dpop_bound_access_tokens_required` ✅ 已存在 + +#### 14.2.2 新增 DPoP 密钥对模型 + +**文件**: `src/mcp/shared/auth.py`(新增) + +**改造内容**: +1. **定义 DPoP 密钥对模型** + ```python + class DPoPKeyPair(BaseModel): + """DPoP 密钥对""" + private_key_pem: str # PEM 格式的私钥 + public_key_jwk: dict[str, Any] # JWK 格式的公钥 + key_id: str | None = None # 可选的密钥 ID + + @classmethod + def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": + """生成新的 DPoP 密钥对""" + # 使用 cryptography 库生成密钥对 + ``` + +2. **扩展 OAuthToken 模型** + ```python + class OAuthToken(BaseModel): + # ... 现有字段 ... + + # DPoP 相关字段 + dpop_key_id: str | None = Field( + None, + description="DPoP 密钥 ID(如果令牌绑定到 DPoP)" + ) + cnf: dict[str, Any] | None = Field( + None, + description="确认信息(包含 jkt 如果令牌绑定到 DPoP)" + ) + ``` + +#### 14.2.3 扩展 TokenStorage 协议 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前协议**: `TokenStorage` + +**改造内容**: +1. **添加 DPoP 密钥对存储** + ```python + class TokenStorage(Protocol): + # ... 现有方法 ... + + async def get_dpop_key_pair(self) -> DPoPKeyPair | None: + """获取存储的 DPoP 密钥对""" + ... + + async def set_dpop_key_pair(self, key_pair: DPoPKeyPair) -> None: + """存储 DPoP 密钥对""" + ... + ``` + +### 14.3 客户端代码改造 + +#### 14.3.1 DPoP 证明生成 + +**文件**: `src/mcp/client/auth/dpop.py`(新建) + +**改造内容**: +1. **实现 DPoP 证明生成器** + ```python + class DPoPProofGenerator: + """DPoP 证明生成器""" + + def __init__(self, key_pair: DPoPKeyPair): + self.key_pair = key_pair + + def generate_proof( + self, + method: str, + uri: str, + access_token: str | None = None, + nonce: str | None = None + ) -> str: + """生成 DPoP 证明 JWT""" + # 1. 构建 JWT claims + claims = { + "htm": method, + "htu": uri, + "iat": int(time.time()), + "jti": secrets.token_urlsafe(32), + "cnf": {"jwk": self.key_pair.public_key_jwk} + } + + # 2. 如果存在访问令牌,添加 ath + if access_token: + claims["ath"] = base64.urlsafe_b64encode( + hashlib.sha256(access_token.encode()).digest() + ).decode().rstrip("=") + + # 3. 如果存在 nonce,添加 nonce + if nonce: + claims["nonce"] = nonce + + # 4. 使用私钥签名 JWT + # 5. 返回 JWT 字符串 + ``` + +2. **实现密钥对生成** + ```python + def generate_dpop_key_pair(algorithm: str = "ES256") -> DPoPKeyPair: + """生成 DPoP 密钥对""" + # 使用 cryptography 库生成密钥对 + # 转换为 PEM 和 JWK 格式 + ``` + +#### 14.3.2 OAuthContext 扩展 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前类**: `OAuthContext` + +**改造内容**: +1. **添加 DPoP 支持字段** + ```python + @dataclass + class OAuthContext: + # ... 现有字段 ... + + # DPoP 支持 + dpop_key_pair: DPoPKeyPair | None = None + dpop_enabled: bool = False + dpop_proof_generator: DPoPProofGenerator | None = None + ``` + +2. **添加 DPoP 初始化方法** + ```python + async def initialize_dpop(self) -> None: + """初始化 DPoP 支持""" + # 1. 检查服务器是否支持 DPoP(从 OAuthMetadata 或 PRM) + # 2. 如果支持,生成或加载密钥对 + # 3. 创建 DPoP 证明生成器 + ``` + +#### 14.3.3 请求准备扩展(添加 DPoP 头) + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前方法**: `_add_auth_header()` + +**改造内容**: +1. **扩展请求准备方法** + ```python + def _add_auth_header(self, request: httpx.Request) -> None: + """添加授权头和 DPoP 头(如果启用)""" + if self.context.current_tokens and self.context.current_tokens.access_token: + request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" + + # 如果启用 DPoP,添加 DPoP 证明 + if self.context.dpop_enabled and self.context.dpop_proof_generator: + dpop_proof = self.context.dpop_proof_generator.generate_proof( + method=request.method, + uri=str(request.url), + access_token=self.context.current_tokens.access_token + ) + request.headers["DPoP"] = dpop_proof + ``` + +2. **令牌请求中的 DPoP** + ```python + async def _exchange_token_authorization_code(...) -> httpx.Request: + """构建令牌交换请求(包含 DPoP 证明)""" + # ... 现有逻辑 ... + + # 如果启用 DPoP,在令牌请求中包含 DPoP 证明 + if self.context.dpop_enabled and self.context.dpop_proof_generator: + dpop_proof = self.context.dpop_proof_generator.generate_proof( + method="POST", + uri=token_url + ) + headers["DPoP"] = dpop_proof + + return httpx.Request("POST", token_url, data=token_data, headers=headers) + ``` + +#### 14.3.4 元数据发现和 DPoP 检测 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前方法**: `_handle_oauth_metadata_response()`, `_handle_protected_resource_response()` + +**改造内容**: +1. **检测服务器 DPoP 支持** + ```python + async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None: + content = await response.aread() + metadata = OAuthMetadata.model_validate_json(content) + self.context.oauth_metadata = metadata + + # 检查服务器是否支持 DPoP + if metadata.dpop_signing_alg_values_supported: + # 服务器支持 DPoP,初始化 DPoP + await self.context.initialize_dpop() + ``` + +2. **从 PRM 检测 DPoP 要求** + ```python + async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: + # ... 现有逻辑 ... + + if metadata.dpop_bound_access_tokens_required: + # 资源服务器要求 DPoP-bound tokens + if not self.context.dpop_enabled: + await self.context.initialize_dpop() + ``` + +#### 14.3.5 令牌刷新中的 DPoP + +**文件**: `src/mcp/client/auth/oauth2.py` + +**当前方法**: `_refresh_token()` + +**改造内容**: +1. **刷新令牌时包含 DPoP 证明** + ```python + async def _refresh_token(self) -> httpx.Request: + """构建令牌刷新请求(包含 DPoP 证明)""" + # ... 现有逻辑 ... + + # 如果启用 DPoP,在刷新请求中包含 DPoP 证明 + if self.context.dpop_enabled and self.context.dpop_proof_generator: + dpop_proof = self.context.dpop_proof_generator.generate_proof( + method="POST", + uri=token_url, + access_token=self.context.current_tokens.access_token if self.context.current_tokens else None + ) + headers["DPoP"] = dpop_proof + + return httpx.Request("POST", token_url, data=refresh_data, headers=headers) + ``` + +### 14.4 服务器端代码改造 + +#### 14.4.1 DPoP 证明验证器 + +**文件**: `src/mcp/server/auth/dpop.py`(新建) + +**改造内容**: +1. **实现 DPoP 证明验证器** + ```python + class DPoPProofVerifier: + """DPoP 证明验证器""" + + def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): + self.allowed_algorithms = allowed_algorithms + self.jti_cache: dict[str, int] = {} # 防重放缓存 + + async def verify_proof( + self, + dpop_proof: str, + method: str, + uri: str, + access_token: str | None = None + ) -> dict[str, Any]: + """验证 DPoP 证明""" + # 1. 解析 JWT + # 2. 验证签名(使用公钥) + # 3. 验证 htm 和 htu 匹配请求 + # 4. 验证 jti 唯一性(防重放) + # 5. 验证 iat 在时间窗口内 + # 6. 如果存在 access_token,验证 ath 匹配 + # 7. 提取公钥(用于令牌绑定) + ``` + +2. **实现重放保护** + ```python + def _check_jti(self, jti: str, iat: int) -> bool: + """检查 jti 是否已使用(防重放)""" + # 清理过期的 jti + current_time = int(time.time()) + self.jti_cache = { + k: v for k, v in self.jti_cache.items() + if current_time - v < 300 # 5 分钟窗口 + } + + # 检查 jti 是否已存在 + if jti in self.jti_cache: + return False + + # 记录 jti + self.jti_cache[jti] = iat + return True + ``` + +#### 14.4.2 令牌颁发中的 DPoP 绑定 + +**文件**: `src/mcp/server/auth/handlers/token.py` + +**当前类**: `TokenHandler` + +**改造内容**: +1. **在令牌响应中包含 DPoP 绑定** + ```python + class TokenHandler: + def __init__(self, ..., dpop_verifier: DPoPProofVerifier | None = None): + # ... 现有参数 ... + self.dpop_verifier = dpop_verifier + + async def handle(self, request: Request) -> Response: + """处理令牌请求(支持 DPoP)""" + # 1. 提取 DPoP 证明(如果存在) + dpop_proof = request.headers.get("DPoP") + + # 2. 如果存在 DPoP 证明,验证它 + dpop_jwk_thumbprint: str | None = None + if dpop_proof: + dpop_info = await self.dpop_verifier.verify_proof( + dpop_proof=dpop_proof, + method=request.method, + uri=str(request.url) + ) + # 计算 JWK Thumbprint (jkt) + dpop_jwk_thumbprint = self._calculate_jwk_thumbprint( + dpop_info["cnf"]["jwk"] + ) + + # 3. 颁发访问令牌(如果 DPoP 存在,绑定到 jkt) + access_token = await self.provider.issue_access_token( + client_id=client_id, + scopes=scopes, + dpop_jkt=dpop_jwk_thumbprint # 绑定到 DPoP 公钥 + ) + + # 4. 在令牌响应中包含 cnf(如果绑定) + token_response = { + "access_token": access_token.token, + "token_type": "Bearer", + "expires_in": access_token.expires_in, + } + + if dpop_jwk_thumbprint: + token_response["cnf"] = { + "jkt": dpop_jwk_thumbprint + } + ``` + +2. **计算 JWK Thumbprint** + ```python + def _calculate_jwk_thumbprint(self, jwk: dict[str, Any]) -> str: + """计算 JWK Thumbprint (RFC 7638)""" + # 1. 规范化 JWK(按字母顺序排序,移除无关字段) + # 2. 计算 SHA-256 哈希 + # 3. Base64 URL 编码 + ``` + +#### 14.4.3 资源服务器验证扩展 + +**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` + +**当前类**: `BearerAuthBackend` + +**改造内容**: +1. **扩展认证后端支持 DPoP 验证** + ```python + class BearerAuthBackend(AuthenticationBackend): + def __init__( + self, + token_verifier: TokenVerifier, + dpop_verifier: DPoPProofVerifier | None = None + ): + self.token_verifier = token_verifier + self.dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection): + # ... 现有 Bearer token 验证 ... + + # 如果令牌包含 cnf.jkt,验证 DPoP 证明 + if auth_info.cnf and auth_info.cnf.get("jkt"): + dpop_proof = conn.headers.get("DPoP") + if not dpop_proof: + return None # DPoP-bound token 必须包含 DPoP 证明 + + # 验证 DPoP 证明 + dpop_info = await self.dpop_verifier.verify_proof( + dpop_proof=dpop_proof, + method=conn.scope["method"], + uri=str(conn.url), + access_token=token + ) + + # 验证 DPoP 公钥与令牌中的 jkt 匹配 + dpop_jkt = self._calculate_jwk_thumbprint(dpop_info["cnf"]["jwk"]) + if dpop_jkt != auth_info.cnf["jkt"]: + return None # 公钥不匹配 + ``` + +2. **扩展 AccessToken 模型** + ```python + class AccessToken(BaseModel): + # ... 现有字段 ... + cnf: dict[str, Any] | None = None # 确认信息(包含 jkt) + ``` + +#### 14.4.4 元数据端点扩展 + +**文件**: `src/mcp/server/auth/routes.py` + +**当前函数**: `build_metadata()` + +**改造内容**: +1. **在 OAuth 元数据中声明 DPoP 支持** + ```python + def build_metadata( + issuer_url: AnyHttpUrl, + ..., + dpop_signing_alg_values_supported: list[str] | None = None, + ) -> OAuthMetadata: + """构建 OAuth 元数据(包含 DPoP 支持)""" + return OAuthMetadata( + # ... 现有字段 ... + dpop_signing_alg_values_supported=dpop_signing_alg_values_supported or ["ES256"], + ) + ``` + +2. **在 PRM 中声明 DPoP 要求** + ```python + def create_protected_resource_routes( + ..., + dpop_bound_access_tokens_required: bool = False, + dpop_signing_alg_values_supported: list[str] | None = None, + ) -> list[Route]: + """创建 PRM 路由(包含 DPoP 配置)""" + metadata = ProtectedResourceMetadata( + # ... 现有字段 ... + dpop_bound_access_tokens_required=dpop_bound_access_tokens_required, + dpop_signing_alg_values_supported=dpop_signing_alg_values_supported or ["ES256"], + ) + ``` + +### 14.5 改造优先级和依赖关系 + +#### 高优先级(核心功能) +1. **DPoP 证明生成和验证**(14.3.1, 14.4.1) + - DPoP 密钥对生成 + - DPoP 证明 JWT 生成 + - DPoP 证明验证器 + +2. **请求准备扩展**(14.3.3) + - 在请求中添加 DPoP 头 + - 令牌请求中的 DPoP 支持 + +#### 中优先级(完整支持) +3. **令牌绑定**(14.4.2) + - 令牌颁发时绑定到 DPoP 公钥 + - 在令牌响应中包含 `cnf.jkt` + +4. **资源服务器验证**(14.4.3) + - 验证 DPoP-bound tokens + - 验证 DPoP 证明与令牌匹配 + +5. **元数据声明**(14.4.4) + - OAuth 元数据中的 DPoP 支持声明 + - PRM 中的 DPoP 要求声明 + +#### 低优先级(优化和增强) +6. **自动检测和启用**(14.3.4) + - 从元数据自动检测 DPoP 支持 + - 自动初始化 DPoP + +7. **密钥对持久化**(14.2.3) + - DPoP 密钥对存储 + - 密钥对重用 + +### 14.6 与多协议设计的关系 + +#### DPoP 是 OAuth 2.0 的扩展 +- **不是独立协议**:DPoP 是 OAuth 2.0 协议内部的增强 +- **在 OAuth2Protocol 中实现**:所有 DPoP 功能都在 `OAuth2Protocol` 类中 +- **不影响协议抽象**:多协议架构不需要修改 + +#### 实现位置 +- **客户端**:在 `OAuth2Protocol` 的 `prepare_request()` 方法中添加 DPoP 头 +- **服务器端**:在 `OAuthTokenVerifier` 中添加 DPoP 验证逻辑 + +#### 向后兼容 +- **可选功能**:DPoP 是可选的,不影响现有 OAuth 流程 +- **渐进式启用**:客户端和服务器可以逐步启用 DPoP +- **降级支持**:如果服务器不支持 DPoP,客户端回退到标准 Bearer token + +### 14.7 总结 + +DPoP 支持的改造点: + +1. **数据模型**: + - 扩展 `OAuthMetadata` 添加 DPoP 字段 + - 新增 `DPoPKeyPair` 模型 + - 扩展 `TokenStorage` 协议 + +2. **客户端**: + - DPoP 证明生成器 + - 请求准备时添加 DPoP 头 + - 自动检测和启用 DPoP + +3. **服务器端**: + - DPoP 证明验证器 + - 令牌绑定到 DPoP 公钥 + - 资源服务器验证 DPoP-bound tokens + +4. **关键特性**: + - 防重放攻击(jti 验证) + - 时间窗口验证(iat 检查) + - 令牌绑定(cnf.jkt) + - 自动检测和降级 + +**与多协议设计的关系**: +- DPoP 是 OAuth 2.0 的扩展,不是新协议 +- 在 `OAuth2Protocol` 实现中添加支持 +- 不影响多协议架构和协议抽象 + +## 十四、DPoP 抽象设计(通用证明持有机制) + +DPoP (Demonstrating Proof-of-Possession, RFC 9449) 虽然最初为 OAuth 2.0 设计,但其核心概念(证明持有加密密钥)是通用的,可以抽象为独立的组件,供多个授权协议使用。 + +### 14.1 DPoP 抽象设计理念 + +#### 14.1.1 为什么需要抽象 +- **通用性**:证明持有机制不仅限于 OAuth 2.0,其他协议(如 API Key、Mutual TLS)也可以使用 +- **代码复用**:避免在每个协议中重复实现 DPoP 逻辑 +- **一致性**:统一的 DPoP 实现确保所有协议使用相同的安全标准 +- **可扩展性**:未来新协议可以轻松集成 DPoP 支持 + +#### 14.1.2 抽象层次 +``` +┌─────────────────────────────────────────┐ +│ 协议层 (OAuth2Protocol, etc.) │ +│ 使用 DPoP 组件增强安全性 │ +├─────────────────────────────────────────┤ +│ DPoP 抽象层 (通用组件) │ +│ - DPoPProofGenerator (客户端) │ +│ - DPoPProofVerifier (服务器端) │ +│ - DPoPKeyPair (密钥管理) │ +├─────────────────────────────────────────┤ +│ 基础层 (JWT, 加密, HTTP) │ +└─────────────────────────────────────────┘ +``` + +### 14.2 通用 DPoP 接口设计 + +#### 14.2.1 客户端 DPoP 接口 + +**文件**: `src/mcp/client/auth/dpop.py`(新建) + +**改造内容**: +1. **定义通用 DPoP 证明生成器接口** + ```python + class DPoPProofGenerator(Protocol): + """DPoP 证明生成器接口(协议无关)""" + + def generate_proof( + self, + method: str, + uri: str, + credential: str | None = None, # 通用凭证(OAuth 中是 access_token) + nonce: str | None = None + ) -> str: + """生成 DPoP 证明 JWT""" + ... + + def get_public_key_jwk(self) -> dict[str, Any]: + """获取公钥 JWK(用于令牌绑定)""" + ... + ``` + +2. **实现通用 DPoP 证明生成器** + ```python + class DPoPProofGeneratorImpl: + """DPoP 证明生成器实现""" + + def __init__(self, key_pair: DPoPKeyPair): + self.key_pair = key_pair + + def generate_proof( + self, + method: str, + uri: str, + credential: str | None = None, + nonce: str | None = None + ) -> str: + """生成 DPoP 证明 JWT(协议无关)""" + claims = { + "htm": method, + "htu": uri, + "iat": int(time.time()), + "jti": secrets.token_urlsafe(32), + "cnf": {"jwk": self.key_pair.public_key_jwk} + } + + # 如果存在凭证,添加凭证哈希(协议特定字段名) + if credential: + claims["ath"] = self._hash_credential(credential) + + if nonce: + claims["nonce"] = nonce + + # 使用私钥签名 JWT + return self._sign_jwt(claims) + + def _hash_credential(self, credential: str) -> str: + """哈希凭证(通用方法)""" + return base64.urlsafe_b64encode( + hashlib.sha256(credential.encode()).digest() + ).decode().rstrip("=") + ``` + +3. **定义密钥对模型(协议无关)** + ```python + class DPoPKeyPair(BaseModel): + """DPoP 密钥对(协议无关)""" + private_key_pem: str + public_key_jwk: dict[str, Any] + key_id: str | None = None + algorithm: str = "ES256" # 默认算法 + + @classmethod + def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": + """生成新的 DPoP 密钥对""" + # 使用 cryptography 库生成密钥对 + # 转换为 PEM 和 JWK 格式 + ``` + +#### 14.2.2 服务器端 DPoP 接口 + +**文件**: `src/mcp/server/auth/dpop.py`(新建) + +**改造内容**: +1. **定义通用 DPoP 验证器接口** + ```python + class DPoPProofVerifier(Protocol): + """DPoP 证明验证器接口(协议无关)""" + + async def verify_proof( + self, + dpop_proof: str, + method: str, + uri: str, + credential: str | None = None # 通用凭证 + ) -> DPoPProofInfo: + """验证 DPoP 证明""" + ... + ``` + +2. **实现通用 DPoP 验证器** + ```python + class DPoPProofInfo(BaseModel): + """DPoP 证明信息""" + public_key_jwk: dict[str, Any] + jwk_thumbprint: str # JWK Thumbprint (jkt) + jti: str + iat: int + + class DPoPProofVerifierImpl: + """DPoP 证明验证器实现""" + + def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): + self.allowed_algorithms = allowed_algorithms + self.jti_cache: dict[str, int] = {} # 防重放缓存 + + async def verify_proof( + self, + dpop_proof: str, + method: str, + uri: str, + credential: str | None = None + ) -> DPoPProofInfo: + """验证 DPoP 证明(协议无关)""" + # 1. 解析 JWT + claims = self._parse_jwt(dpop_proof) + + # 2. 验证签名 + self._verify_signature(dpop_proof, claims) + + # 3. 验证 htm 和 htu 匹配请求 + if claims["htm"] != method or claims["htu"] != uri: + raise DPoPValidationError("HTTP method or URI mismatch") + + # 4. 验证 jti 唯一性(防重放) + if not self._check_jti(claims["jti"], claims["iat"]): + raise DPoPValidationError("Replay attack detected") + + # 5. 验证 iat 在时间窗口内 + self._verify_iat(claims["iat"]) + + # 6. 如果存在凭证,验证 ath 匹配 + if credential and "ath" in claims: + expected_ath = self._hash_credential(credential) + if claims["ath"] != expected_ath: + raise DPoPValidationError("Credential hash mismatch") + + # 7. 提取公钥信息 + public_key_jwk = claims["cnf"]["jwk"] + jwk_thumbprint = self._calculate_jwk_thumbprint(public_key_jwk) + + return DPoPProofInfo( + public_key_jwk=public_key_jwk, + jwk_thumbprint=jwk_thumbprint, + jti=claims["jti"], + iat=claims["iat"] + ) + ``` + +#### 14.2.3 DPoP 存储接口(协议无关) + +**文件**: `src/mcp/client/auth/dpop.py` + +**改造内容**: +1. **定义 DPoP 存储接口** + ```python + class DPoPStorage(Protocol): + """DPoP 密钥对存储接口(协议无关)""" + + async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: + """获取指定协议的 DPoP 密钥对""" + ... + + async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: + """存储指定协议的 DPoP 密钥对""" + ... + ``` + +2. **实现内存存储(示例)** + ```python + class InMemoryDPoPStorage: + """内存 DPoP 存储实现""" + def __init__(self): + self._key_pairs: dict[str, DPoPKeyPair] = {} + + async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: + return self._key_pairs.get(protocol_id) + + async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: + self._key_pairs[protocol_id] = key_pair + ``` + +### 14.3 协议集成接口 + +#### 14.3.1 协议 DPoP 支持接口 + +**文件**: `src/mcp/client/auth/protocol.py` + +**改造内容**: +1. **扩展 AuthProtocol 接口** + ```python + class AuthProtocol(Protocol): + # ... 现有方法 ... + + # DPoP 支持(可选) + def supports_dpop(self) -> bool: + """协议是否支持 DPoP""" + ... + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + """获取 DPoP 证明生成器(如果支持)""" + ... + + def prepare_request_with_dpop( + self, + request: httpx.Request, + credentials: AuthCredentials, + dpop_generator: DPoPProofGenerator | None = None + ) -> None: + """准备请求(包含 DPoP 头,如果启用)""" + ... + ``` + +#### 14.3.2 OAuth 2.0 协议集成 + +**文件**: `src/mcp/client/auth/protocols/oauth2.py` + +**改造内容**: +1. **OAuth2Protocol 实现 DPoP 支持** + ```python + class OAuth2Protocol(AuthProtocol): + def __init__( + self, + ..., + dpop_enabled: bool = False, + dpop_storage: DPoPStorage | None = None + ): + # ... 现有初始化 ... + self.dpop_enabled = dpop_enabled + self.dpop_storage = dpop_storage + self.dpop_generator: DPoPProofGenerator | None = None + + def supports_dpop(self) -> bool: + """OAuth 2.0 支持 DPoP""" + return self.dpop_enabled + + async def initialize_dpop(self) -> None: + """初始化 DPoP 支持""" + if not self.dpop_enabled: + return + + # 检查服务器是否支持 DPoP + if not self._server_supports_dpop(): + self.dpop_enabled = False + return + + # 加载或生成密钥对 + key_pair = await self.dpop_storage.get_key_pair("oauth2") + if not key_pair: + key_pair = DPoPKeyPair.generate() + await self.dpop_storage.set_key_pair("oauth2", key_pair) + + self.dpop_generator = DPoPProofGeneratorImpl(key_pair) + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + return self.dpop_generator + + def prepare_request_with_dpop( + self, + request: httpx.Request, + credentials: AuthCredentials, + dpop_generator: DPoPProofGenerator | None = None + ) -> None: + """准备 OAuth 请求(包含 DPoP)""" + # 添加 Bearer token + if isinstance(credentials, OAuthCredentials): + request.headers["Authorization"] = f"Bearer {credentials.access_token}" + + # 如果启用 DPoP,添加 DPoP 证明 + generator = dpop_generator or self.dpop_generator + if generator: + dpop_proof = generator.generate_proof( + method=request.method, + uri=str(request.url), + credential=credentials.access_token # OAuth 特定:使用 access_token + ) + request.headers["DPoP"] = dpop_proof + ``` + +#### 14.3.3 其他协议集成示例 + +**文件**: `src/mcp/client/auth/protocols/api_key.py`(示例) + +**改造内容**: +1. **API Key 协议也可以使用 DPoP** + ```python + class APIKeyProtocol(AuthProtocol): + def prepare_request_with_dpop( + self, + request: httpx.Request, + credentials: AuthCredentials, + dpop_generator: DPoPProofGenerator | None = None + ) -> None: + """准备 API Key 请求(可选 DPoP 增强)""" + if isinstance(credentials, APIKeyCredentials): + request.headers["X-API-Key"] = credentials.api_key + + # 可选:使用 DPoP 增强安全性 + if self.dpop_enabled and dpop_generator: + dpop_proof = dpop_generator.generate_proof( + method=request.method, + uri=str(request.url), + credential=credentials.api_key # API Key 特定:使用 api_key + ) + request.headers["DPoP"] = dpop_proof + ``` + +### 14.4 多协议提供者集成 + +#### 14.4.1 MultiProtocolAuthProvider 中的 DPoP 支持 + +**文件**: `src/mcp/client/auth/multi_protocol.py` + +**改造内容**: +1. **在提供者中管理 DPoP** + ```python + class MultiProtocolAuthProvider(httpx.Auth): + def __init__( + self, + ..., + dpop_storage: DPoPStorage | None = None, + dpop_enabled: bool = False + ): + # ... 现有初始化 ... + self.dpop_storage = dpop_storage or InMemoryDPoPStorage() + self.dpop_enabled = dpop_enabled + + async def _prepare_request( + self, + request: httpx.Request, + protocol: AuthProtocol, + credentials: AuthCredentials + ) -> None: + """准备请求(包含 DPoP,如果协议支持)""" + # 获取协议的 DPoP 生成器 + dpop_generator = None + if self.dpop_enabled and protocol.supports_dpop(): + dpop_generator = protocol.get_dpop_proof_generator() + if not dpop_generator: + # 初始化 DPoP + await protocol.initialize_dpop() + dpop_generator = protocol.get_dpop_proof_generator() + + # 协议特定的请求准备(包含 DPoP) + protocol.prepare_request_with_dpop( + request=request, + credentials=credentials, + dpop_generator=dpop_generator + ) + ``` + +### 14.5 服务器端集成 + +#### 14.5.1 通用 DPoP 验证器集成 + +**文件**: `src/mcp/server/auth/verifiers.py` + +**改造内容**: +1. **在验证器中集成 DPoP** + ```python + class CredentialVerifier(Protocol): + """凭证验证器接口""" + + async def verify( + self, + request: Request, + dpop_verifier: DPoPProofVerifier | None = None + ) -> AuthInfo | None: + """验证凭证(可选 DPoP 验证)""" + ... + ``` + +2. **OAuth Token 验证器集成 DPoP** + ```python + class OAuthTokenVerifier(CredentialVerifier): + def __init__( + self, + token_verifier: TokenVerifier, + dpop_verifier: DPoPProofVerifier | None = None + ): + self.token_verifier = token_verifier + self.dpop_verifier = dpop_verifier + + async def verify( + self, + request: Request, + dpop_verifier: DPoPProofVerifier | None = None + ) -> AuthInfo | None: + """验证 OAuth token(包含 DPoP 验证)""" + # 1. 验证 Bearer token + token = self._extract_bearer_token(request) + auth_info = await self.token_verifier.verify_token(token) + + if not auth_info: + return None + + # 2. 如果令牌绑定到 DPoP,验证 DPoP 证明 + verifier = dpop_verifier or self.dpop_verifier + if auth_info.cnf and auth_info.cnf.get("jkt") and verifier: + dpop_proof = request.headers.get("DPoP") + if not dpop_proof: + return None # DPoP-bound token 必须包含 DPoP 证明 + + # 验证 DPoP 证明 + dpop_info = await verifier.verify_proof( + dpop_proof=dpop_proof, + method=request.method, + uri=str(request.url), + credential=token # OAuth 特定:使用 access_token + ) + + # 验证公钥匹配 + if dpop_info.jwk_thumbprint != auth_info.cnf["jkt"]: + return None + + return auth_info + ``` + +#### 14.5.2 多协议认证后端集成 + +**文件**: `src/mcp/server/auth/verifiers.py` + +**改造内容**: +1. **MultiProtocolAuthBackend 中的 DPoP 支持** + ```python + class MultiProtocolAuthBackend(AuthenticationBackend): + def __init__( + self, + verifiers: dict[str, CredentialVerifier], + dpop_verifier: DPoPProofVerifier | None = None + ): + self.verifiers = verifiers + self.dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection): + """尝试所有协议的验证(包含 DPoP)""" + for protocol_id, verifier in self.verifiers.items(): + result = await verifier.verify( + request=Request(conn.scope, conn.receive), + dpop_verifier=self.dpop_verifier + ) + if result: + return result + return None + ``` + +### 14.6 元数据扩展(协议无关) + +#### 14.6.1 协议元数据中的 DPoP 支持声明 + +**文件**: `src/mcp/shared/auth.py` + +**改造内容**: +1. **在 AuthProtocolMetadata 中添加 DPoP 支持** + ```python + class AuthProtocolMetadata(BaseModel): + # ... 现有字段 ... + + # DPoP 支持(协议无关) + dpop_signing_alg_values_supported: list[str] | None = Field( + None, + description="支持的 DPoP 签名算法(如果协议支持 DPoP)" + ) + dpop_bound_credentials_required: bool | None = Field( + None, + description="是否要求 DPoP-bound 凭证(协议特定术语)" + ) + ``` + +2. **OAuth 特定字段映射** + - OAuth 中:`dpop_bound_access_tokens_required` + - 通用术语:`dpop_bound_credentials_required` + - 在 OAuth 实现中映射这两个字段 + +### 14.7 改造点总结 + +#### 14.7.1 新增文件 + +1. **`src/mcp/client/auth/dpop.py`**(新建) + - `DPoPProofGenerator` 接口和实现 + - `DPoPKeyPair` 模型 + - `DPoPStorage` 接口 + - 密钥对生成和 JWK Thumbprint 计算 + +2. **`src/mcp/server/auth/dpop.py`**(新建) + - `DPoPProofVerifier` 接口和实现 + - `DPoPProofInfo` 模型 + - 重放保护逻辑 + +#### 14.7.2 修改文件 + +1. **`src/mcp/client/auth/protocol.py`** + - 扩展 `AuthProtocol` 接口添加 DPoP 支持方法 + +2. **`src/mcp/client/auth/protocols/oauth2.py`** + - 实现 DPoP 支持方法 + - 集成 DPoP 证明生成器 + +3. **`src/mcp/client/auth/multi_protocol.py`** + - 在提供者中管理 DPoP 存储和生成器 + +4. **`src/mcp/server/auth/verifiers.py`** + - 在验证器中集成 DPoP 验证 + +5. **`src/mcp/shared/auth.py`** + - 在 `AuthProtocolMetadata` 中添加 DPoP 字段 + +### 14.8 使用示例 + +#### 14.8.1 OAuth 2.0 使用 DPoP + +```python +# 客户端 +dpop_storage = InMemoryDPoPStorage() +oauth_protocol = OAuth2Protocol( + ..., + dpop_enabled=True, + dpop_storage=dpop_storage +) + +# 服务器端 +dpop_verifier = DPoPProofVerifierImpl(allowed_algorithms=["ES256"]) +oauth_verifier = OAuthTokenVerifier( + token_verifier=token_verifier, + dpop_verifier=dpop_verifier +) +``` + +#### 14.8.2 API Key 使用 DPoP(可选增强) + +```python +# 客户端 +api_key_protocol = APIKeyProtocol( + ..., + dpop_enabled=True, # 可选:使用 DPoP 增强 API Key 安全性 + dpop_storage=dpop_storage +) +``` + +### 14.9 优势总结 + +#### 14.9.1 抽象设计的优势 + +1. **代码复用**:DPoP 逻辑只需实现一次,所有协议共享 +2. **一致性**:所有协议使用相同的 DPoP 实现和安全标准 +3. **灵活性**:协议可以选择是否支持 DPoP +4. **可扩展性**:新协议可以轻松集成 DPoP 支持 +5. **测试简化**:DPoP 逻辑可以独立测试 + +#### 14.9.2 与多协议架构的集成 + +- **协议无关**:DPoP 组件不依赖特定协议 +- **可选增强**:协议可以选择性地使用 DPoP +- **统一接口**:所有协议使用相同的 DPoP 接口 +- **向后兼容**:不支持 DPoP 的协议仍然可以正常工作 + +### 14.10 总结 + +DPoP 抽象设计的关键点: + +1. **独立组件**:DPoP 作为独立的通用组件,不绑定到特定协议 +2. **统一接口**:所有协议使用相同的 DPoP 接口 +3. **可选集成**:协议可以选择是否支持 DPoP +4. **灵活使用**:可以在 OAuth、API Key 等协议中使用 +5. **向后兼容**:不影响不支持 DPoP 的协议 + +**实现层次**: +- **基础层**:DPoP 核心组件(生成器、验证器、密钥管理) +- **集成层**:协议特定的 DPoP 集成 +- **应用层**:多协议提供者中的 DPoP 管理 + +这种设计使得 DPoP 成为一个可复用的安全增强组件,可以在任何需要证明持有机制的授权协议中使用。 diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md new file mode 100644 index 000000000..87422522a --- /dev/null +++ b/src/mcp/client/auth/multi-protocol-refactoring-plan.md @@ -0,0 +1,1313 @@ +# MCP 多协议授权支持改造计划 + +> 基于章节12.5(与OAuth的区别)和章节13(现有代码改造点清单),结合DPoP抽象设计,制定的完整改造计划 + +## 一、改造目标 + +### 1.1 核心目标 +- 支持多个授权协议(OAuth 2.0、API Key、Mutual TLS等) +- 保持与现有OAuth实现的完全向后兼容 +- 提供统一的协议抽象接口 +- 支持DPoP作为可选的通用安全增强组件 + +### 1.2 设计原则 +1. **协议抽象**:统一的协议接口,所有授权协议实现相同的基础接口 +2. **向后兼容**:现有OAuth 2.0实现无需修改即可工作 +3. **协议发现**:服务器声明支持的协议,客户端自动发现和选择 +4. **灵活扩展**:开发者可以轻松添加新的授权协议 +5. **标准兼容**:基于现有HTTP和MCP规范,最小化扩展 + +## 二、功能分类(基于章节12.5) + +### 2.1 不需要实现的功能(OAuth特定) +以下功能是OAuth 2.0协议特有的,新协议不需要实现: + +- ❌ **授权码流程**(OAuth特定) +- ❌ **PKCE**(OAuth特定) +- ❌ **令牌交换**(OAuth特定) +- ❌ **Refresh Token**(OAuth特定) +- ❌ **Scope模型**(OAuth特定,除非新协议也有类似概念) +- ❌ **OAuth客户端认证方法**(client_secret_basic等) + +### 2.2 必须实现的功能(MCP通用) +以下功能是MCP授权规范要求的,所有协议都必须支持: + +- ✅ **PRM支持和协议声明** +- ✅ **WWW-Authenticate头解析/构建** +- ✅ **协议发现机制** +- ✅ **自动触发授权流程**(401响应) +- ✅ **凭证管理和验证** +- ✅ **请求认证信息准备** + +### 2.3 可选实现的功能(协议特定) +以下功能取决于协议的具体需求: + +- ⚠️ **客户端注册**(取决于协议需求) +- ⚠️ **权限模型**(取决于协议需求) +- ⚠️ **凭证刷新**(取决于协议需求) +- ⚠️ **元数据发现**(取决于协议复杂度) + +### 2.4 通用安全增强(DPoP) +DPoP作为独立的通用组件,协议可以选择性使用: + +- ⚠️ **DPoP支持**(可选,但建议支持以增强安全性) + +## 三、改造点详细清单 + +### 3.1 数据模型层改造 + +#### 3.1.1 扩展 ProtectedResourceMetadata(PRM) + +**文件**: `src/mcp/shared/auth.py` + +**优先级**: 🔴 高 + +**改造内容**: +1. **新增协议元数据模型** + ```python + class AuthProtocolMetadata(BaseModel): + """单个授权协议的元数据(MCP扩展)""" + protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") + protocol_version: str + metadata_url: AnyHttpUrl | None = None + endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) + capabilities: list[str] = Field(default_factory=list) + client_auth_methods: list[str] | None = None # OAuth特定 + grant_types: list[str] | None = None # OAuth特定 + scopes_supported: list[str] | None = None # OAuth特定 + # DPoP支持(协议无关) + dpop_signing_alg_values_supported: list[str] | None = None + dpop_bound_credentials_required: bool | None = None + additional_params: dict[str, Any] = Field(default_factory=dict) + ``` + +2. **扩展 ProtectedResourceMetadata** + ```python + class ProtectedResourceMetadata(BaseModel): + # 保持RFC 9728必需字段不变(向后兼容) + resource: AnyHttpUrl + authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) + + # ... 现有字段 ... + + # MCP扩展字段(使用mcp_前缀) + mcp_auth_protocols: list[AuthProtocolMetadata] | None = Field( + None, + description="MCP扩展:支持的授权协议列表" + ) + mcp_default_auth_protocol: str | None = Field( + None, + description="MCP扩展:默认推荐的授权协议ID" + ) + mcp_auth_protocol_preferences: dict[str, int] | None = Field( + None, + description="MCP扩展:协议优先级映射" + ) + ``` + +3. **向后兼容处理** + - 如果`mcp_auth_protocols`为空,自动从`authorization_servers`创建OAuth 2.0协议元数据 + - 标准OAuth客户端可以忽略`mcp_*`扩展字段 + +#### 3.1.2 新增通用凭证模型 + +**文件**: `src/mcp/shared/auth.py` + +**优先级**: 🔴 高 + +**改造内容**: +1. **定义基础凭证接口** + ```python + class AuthCredentials(BaseModel): + """通用凭证基类""" + protocol_id: str + expires_at: int | None = None + + class OAuthCredentials(AuthCredentials): + """OAuth凭证(现有OAuthToken的包装)""" + protocol_id: str = "oauth2" + access_token: str + token_type: Literal["Bearer"] = "Bearer" + refresh_token: str | None = None + scope: str | None = None + cnf: dict[str, Any] | None = None # DPoP绑定信息 + + class APIKeyCredentials(AuthCredentials): + """API Key凭证""" + protocol_id: str = "api_key" + api_key: str + key_id: str | None = None + ``` + +2. **扩展TokenStorage协议** + ```python + class TokenStorage(Protocol): + async def get_tokens(self) -> AuthCredentials | None: ... + async def set_tokens(self, tokens: AuthCredentials) -> None: ... + # 保持现有方法以支持向后兼容 + async def get_client_info(self) -> OAuthClientInformationFull | None: ... + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: ... + ``` + +#### 3.1.3 新增协议抽象接口 + +**文件**: `src/mcp/client/auth/protocol.py`(新建) + +**优先级**: 🔴 高 + +**改造内容**: +1. **定义基础协议抽象接口(必需方法)** + ```python + class AuthProtocol(Protocol): + """授权协议基础接口(所有协议必须实现)""" + protocol_id: str + protocol_version: str + + async def authenticate( + self, + context: AuthContext + ) -> AuthCredentials: + """执行协议特定的认证流程(必需)""" + ... + + def prepare_request( + self, + request: httpx.Request, + credentials: AuthCredentials + ) -> None: + """为请求添加协议特定的认证信息(必需)""" + ... + + def validate_credentials( + self, + credentials: AuthCredentials + ) -> bool: + """验证凭证是否有效(客户端,必需)""" + ... + + async def discover_metadata( + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + """发现协议特定的元数据(可选,默认返回None)""" + return None + ``` + +2. **定义可选功能扩展接口** + ```python + class ClientRegisterableProtocol(AuthProtocol): + """支持客户端注册的协议扩展接口(可选)""" + async def register_client( + self, + context: AuthContext + ) -> ClientRegistrationResult | None: + """协议特定的客户端注册""" + ... + + class DPoPEnabledProtocol(AuthProtocol): + """支持DPoP的协议扩展接口(可选)""" + def supports_dpop(self) -> bool: + """协议是否支持DPoP""" + ... + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + """获取DPoP证明生成器""" + ... + + async def initialize_dpop(self) -> None: + """初始化DPoP支持""" + ... + ``` + +2. **定义服务器端验证器接口** + ```python + class CredentialVerifier(Protocol): + """凭证验证器接口""" + async def verify( + self, + request: Request, + dpop_verifier: DPoPProofVerifier | None = None + ) -> AuthInfo | None: + """验证请求中的凭证(可选DPoP验证)""" + ... + ``` + +### 3.2 客户端代码改造 + +#### 3.2.1 WWW-Authenticate头解析扩展 + +**文件**: `src/mcp/client/auth/utils.py` + +**优先级**: 🔴 高 + +**改造内容**: +1. **新增协议相关字段提取** + ```python + def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: + """提取auth_protocols字段""" + return extract_field_from_www_auth(response, "auth_protocols") + + def extract_default_protocol_from_www_auth(response: Response) -> str | None: + """提取default_protocol字段""" + return extract_field_from_www_auth(response, "default_protocol") + + def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: + """提取protocol_preferences字段""" + prefs_str = extract_field_from_www_auth(response, "protocol_preferences") + if not prefs_str: + return None + # 解析格式: "oauth2:1,api_key:2" + preferences = {} + for item in prefs_str.split(","): + proto, priority = item.split(":") + preferences[proto] = int(priority) + return preferences + ``` + +2. **扩展解析逻辑** + - 支持多种认证方式:OAuth 使用标准 `Bearer`;API Key 使用 `X-API-Key` 或可选 `Authorization: Bearer `(标准 scheme,不解析非标准 `ApiKey`);Mutual TLS(mTLS,客户端证书)在 TLS/HTTPS 连接层(握手时)处理;与 IANA 的 "Mutual" scheme(RFC 8120,密码双向认证)无关 + - 解析协议声明和优先级 + - 支持多个认证方案(如果服务器支持) + +#### 3.2.2 协议发现机制 + +**文件**: `src/mcp/client/auth/utils.py` + +**优先级**: 🟡 中 + +**改造内容**: +1. **新增统一能力发现端点支持** + ```python + async def discover_authorization_servers( + resource_url: str, + http_client: httpx.AsyncClient + ) -> list[AuthProtocolMetadata]: + """统一的授权服务器发现流程""" + # 1. 首先访问统一的能力发现端点 + discovery_url = f"{resource_url}/.well-known/authorization_servers" + try: + response = await http_client.get(discovery_url) + if response.status_code == 200: + data = response.json() + return [ + AuthProtocolMetadata(**proto_data) + for proto_data in data.get("protocols", []) + ] + except Exception: + pass + + # 2. 回退:从PRM中获取协议信息 + # 3. 回退:使用协议特定的well-known URI + ``` + +2. **新增协议特定的元数据发现** + ```python + async def discover_protocol_metadata( + protocol_id: str, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None + ) -> AuthProtocolMetadata | None: + """协议特定的元数据发现""" + # 根据协议ID调用相应的发现逻辑 + # OAuth: 使用RFC 8414发现 + # API Key: 使用自定义发现端点 + # 其他协议: 协议特定的发现逻辑 + ``` + +3. **修改现有发现函数** + - `build_oauth_authorization_server_metadata_discovery_urls()`改为协议特定的发现函数 + - 支持路径感知的协议元数据发现端点 + +#### 3.2.3 协议注册和选择机制 + +**文件**: `src/mcp/client/auth/registry.py`(新建) + +**优先级**: 🟡 中 + +**改造内容**: +1. **实现协议注册表** + ```python + class AuthProtocolRegistry: + """协议注册表""" + _protocols: dict[str, type[AuthProtocol]] = {} + + @classmethod + def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]): + """注册协议实现""" + cls._protocols[protocol_id] = protocol_class + + @classmethod + def get_protocol_class(cls, protocol_id: str) -> type[AuthProtocol] | None: + """获取协议实现类""" + return cls._protocols.get(protocol_id) + + @classmethod + def select_protocol( + cls, + available_protocols: list[str], + default_protocol: str | None = None, + preferences: dict[str, int] | None = None + ) -> str | None: + """选择协议""" + # 1. 过滤客户端支持的协议 + supported = [p for p in available_protocols if p in cls._protocols] + if not supported: + return None + + # 2. 如果有默认协议且支持,优先选择 + if default_protocol and default_protocol in supported: + return default_protocol + + # 3. 如果有优先级,按优先级排序 + if preferences: + supported.sort(key=lambda p: preferences.get(p, 999)) + + # 4. 返回第一个支持的协议 + return supported[0] if supported else None + ``` + +2. **协议选择逻辑** + - 根据优先级、默认协议、客户端支持情况选择协议 + - 支持协议切换(如果第一个协议失败) + +#### 3.2.4 OAuthClientProvider重构 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**优先级**: 🟡 中 + +**改造内容**: +1. **抽象为多协议提供者** + ```python + class MultiProtocolAuthProvider(httpx.Auth): + """多协议认证提供者""" + requires_response_body = True + + def __init__( + self, + server_url: str, + storage: TokenStorage, + protocols: list[AuthProtocol] | None = None, + dpop_storage: DPoPStorage | None = None, + dpop_enabled: bool = False, + timeout: float = 300.0, + ): + self.server_url = server_url + self.storage = storage + self.protocols = protocols or [] + self.dpop_storage = dpop_storage or InMemoryDPoPStorage() + self.dpop_enabled = dpop_enabled + self.timeout = timeout + self.registry = AuthProtocolRegistry() + self._initialized = False + self._current_protocol: AuthProtocol | None = None + + async def async_auth_flow( + self, + request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """HTTPX认证流程入口""" + async with self._lock: + if not self._initialized: + await self._initialize() + + # 1. 检查存储的凭证 + credentials = await self.storage.get_tokens() + + # 2. 如果凭证无效,触发协议发现和选择 + if not credentials or not self._is_credentials_valid(credentials): + await self._discover_and_authenticate(request) + credentials = await self.storage.get_tokens() + + # 3. 准备请求认证信息 + if credentials: + await self._prepare_request(request, credentials) + + # 4. 发送请求 + response = yield request + + # 5. 处理401/403响应 + if response.status_code == 401: + await self._handle_401_response(response, request) + elif response.status_code == 403: + await self._handle_403_response(response, request) + ``` + +2. **OAuthClientProvider适配** + - 将`OAuthClientProvider`改为`OAuth2Protocol`实现 + - 保持现有API不变(向后兼容) + - 内部使用`MultiProtocolAuthProvider` + +3. **协议上下文扩展** + ```python + @dataclass + class AuthContext: + """通用认证上下文""" + server_url: str + storage: TokenStorage + protocol_id: str + protocol_metadata: AuthProtocolMetadata | None = None + current_credentials: AuthCredentials | None = None + # DPoP支持(可选,阶段4实现) + dpop_storage: DPoPStorage | None = None + dpop_enabled: bool = False + ``` + +#### 3.2.5 请求认证信息准备 + +**文件**: `src/mcp/client/auth/multi_protocol.py` + +**优先级**: 🔴 高 + +**改造内容**: +1. **在MultiProtocolAuthProvider中实现** + ```python + async def _prepare_request( + self, + request: httpx.Request, + credentials: AuthCredentials + ) -> None: + """准备请求(包含DPoP,如果协议支持)""" + # 获取协议实例 + protocol = self._get_protocol(credentials.protocol_id) + if not protocol: + raise AuthError(f"Protocol {credentials.protocol_id} not found") + + # 协议特定的请求准备(必需) + protocol.prepare_request(request, credentials) + + # DPoP支持(可选,仅在协议实现DPoPEnabledProtocol时) + if self.dpop_enabled and isinstance(protocol, DPoPEnabledProtocol): + if protocol.supports_dpop(): + dpop_generator = protocol.get_dpop_proof_generator() + if not dpop_generator: + await protocol.initialize_dpop() + dpop_generator = protocol.get_dpop_proof_generator() + + if dpop_generator: + # 获取凭证字符串(协议特定) + credential_str = self._get_credential_string(credentials) + dpop_proof = dpop_generator.generate_proof( + method=request.method, + uri=str(request.url), + credential=credential_str + ) + request.headers["DPoP"] = dpop_proof + ``` + + **注意**:DPoP集成是可选功能,仅在阶段4实现。在阶段2-3中,可以暂时忽略DPoP相关代码。 + +2. **协议特定的实现示例** + - OAuth: `Authorization: Bearer ` + - API Key: 优先 `X-API-Key: `,可选 `Authorization: Bearer `(标准 scheme;不解析非标准 `ApiKey`;服务端通过验证器顺序区分,不在 token 内加前缀) + - Mutual TLS(mTLS): 在 TLS 握手时处理(非 HTTP Authorization scheme) + +#### 3.2.6 凭证存储扩展 + +**文件**: `src/mcp/client/auth/oauth2.py` + +**优先级**: 🟢 低 + +**改造内容**: +1. **凭证序列化/反序列化** + ```python + def serialize_credentials(credentials: AuthCredentials) -> dict[str, Any]: + """序列化凭证""" + data = credentials.model_dump() + data["_type"] = credentials.__class__.__name__ + return data + + def deserialize_credentials(data: dict[str, Any]) -> AuthCredentials: + """反序列化凭证""" + type_name = data.pop("_type") + if type_name == "OAuthCredentials": + return OAuthCredentials(**data) + elif type_name == "APIKeyCredentials": + return APIKeyCredentials(**data) + # ... 其他类型 + ``` + +#### 3.2.7 API Key 认证方案约定(方案 A) + +**约定**(与前述 3.2.5 协议特定的实现示例一致): +- **标准兼容**:不解析非标准 `Authorization: ApiKey `(`ApiKey` 非 IANA 注册 scheme);API Key 使用标准 `Bearer` 或专用 header。 +- **服务端**:优先从 `X-API-Key` header 读取;可选从 `Authorization: Bearer ` 读取并在 `valid_keys` 中查找;由 `MultiProtocolAuthBackend` 的验证器顺序区分(OAuthTokenVerifier 先尝试 Bearer → TokenVerifier,APIKeyVerifier 再尝试 X-API-Key / Bearer-in-valid_keys)。 +- **客户端**:推荐使用 `X-API-Key: `;若需统一走 Bearer,可发送 `Authorization: Bearer `(服务端需配置允许 Bearer 作为 API Key)。 +- **不在 token 内加前缀**:不要求 `apikey:xxx` 等格式,符合 RFC 6750 Bearer token 为 opaque string 的语义;区分由验证逻辑与 header 完成。 + +### 3.3 服务器端代码改造 + +#### 3.3.1 PRM端点扩展 + +**文件**: `src/mcp/server/auth/routes.py` + +**优先级**: 🟡 中 + +**改造内容**: +1. **扩展函数签名** + ```python + def create_protected_resource_routes( + resource_url: AnyHttpUrl, + authorization_servers: list[AnyHttpUrl], + scopes_supported: list[str] | None = None, + # 新增参数 + auth_protocols: list[AuthProtocolMetadata] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + ) -> list[Route]: + """创建PRM路由(支持多协议)""" + metadata = ProtectedResourceMetadata( + resource=resource_url, + authorization_servers=authorization_servers, # 保持向后兼容 + scopes_supported=scopes_supported, + mcp_auth_protocols=auth_protocols, # 新增 + mcp_default_auth_protocol=default_protocol, # 新增 + mcp_auth_protocol_preferences=protocol_preferences, # 新增 + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + handler = ProtectedResourceMetadataHandler(metadata) + # ... 路由创建逻辑 + ``` + +#### 3.3.2 统一能力发现端点 + +**文件**: `src/mcp/server/auth/routes.py`(新增函数) + +**优先级**: 🟡 中 + +**改造内容**: +1. **新增统一发现端点** + ```python + def create_authorization_servers_discovery_routes( + resource_url: AnyHttpUrl, + auth_protocols: list[AuthProtocolMetadata], + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + ) -> list[Route]: + """创建统一的能力发现端点/.well-known/authorization_servers""" + handler = AuthorizationServersDiscoveryHandler( + auth_protocols=auth_protocols, + default_protocol=default_protocol, + protocol_preferences=protocol_preferences, + ) + + return [ + Route( + "/.well-known/authorization_servers", + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=["GET", "OPTIONS"], + ) + ] + ``` + +2. **实现端点处理器** + ```python + class AuthorizationServersDiscoveryHandler: + async def handle(self, request: Request) -> Response: + """返回服务器支持的所有授权协议列表""" + response_data = { + "protocols": [ + protocol.model_dump() + for protocol in self.auth_protocols + ] + } + if self.default_protocol: + response_data["default_protocol"] = self.default_protocol + if self.protocol_preferences: + response_data["protocol_preferences"] = self.protocol_preferences + + return JSONResponse(response_data) + ``` + +#### 3.3.3 WWW-Authenticate头构建扩展 + +**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` + +**优先级**: 🔴 高 + +**改造内容**: +1. **扩展错误响应构建** + ```python + async def _send_auth_error( + self, + send: Send, + status_code: int, + error: str, + description: str, + # 新增参数 + resource_metadata_url: AnyHttpUrl | None = None, + auth_protocols: list[str] | None = None, + default_protocol: str | None = None, + protocol_preferences: dict[str, int] | None = None, + ) -> None: + """构建扩展的WWW-Authenticate头""" + parts = [ + f'error="{error}"', + f'error_description="{description}"' + ] + + if resource_metadata_url: + parts.append(f'resource_metadata="{resource_metadata_url}"') + + if auth_protocols: + protocols_str = " ".join(auth_protocols) + parts.append(f'auth_protocols="{protocols_str}"') + + if default_protocol: + parts.append(f'default_protocol="{default_protocol}"') + + if protocol_preferences: + prefs_str = ",".join( + f"{proto}:{priority}" + for proto, priority in protocol_preferences.items() + ) + parts.append(f'protocol_preferences="{prefs_str}"') + + # 确定认证方案(根据支持的协议) + scheme = self._determine_auth_scheme(auth_protocols) + www_auth = f"{scheme} {', '.join(parts)}" + + # 发送响应 + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [ + [b"www-authenticate", www_auth.encode()], + [b"content-type", b"application/json"], + ], + }) + ``` + +2. **修改RequireAuthMiddleware** + - 添加协议信息参数 + - 在401/403响应中包含协议声明 + +#### 3.3.4 认证后端重构 + +**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` + +**优先级**: 🟡 中 + +**改造内容**: +1. **新增多协议认证后端** + ```python + class MultiProtocolAuthBackend(AuthenticationBackend): + """多协议认证后端""" + def __init__( + self, + verifiers: dict[str, CredentialVerifier], + dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 + ): + self.verifiers = verifiers + self.dpop_verifier = dpop_verifier + + async def authenticate(self, conn: HTTPConnection): + """尝试所有支持的协议""" + request = Request(conn.scope, conn.receive) + + for protocol_id, verifier in self.verifiers.items(): + result = await verifier.verify( + request=request, + dpop_verifier=self.dpop_verifier # 可选,阶段4实现 + ) + if result: + return result + return None + ``` + + **注意**:DPoP验证器参数是可选的,在阶段2-3中可以为None。 + +2. **BearerAuthBackend适配** + - 将`BearerAuthBackend`改为OAuth特定的验证器 + - 在`MultiProtocolAuthBackend`中注册 + +3. **新增协议特定的验证器** + ```python + class OAuthTokenVerifier(CredentialVerifier): + """OAuth Token验证器""" + def __init__( + self, + token_verifier: TokenVerifier, + dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 + ): + self.token_verifier = token_verifier + self.dpop_verifier = dpop_verifier + + async def verify( + self, + request: Request, + dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 + ) -> AuthInfo | None: + """验证OAuth token(包含DPoP验证,如果启用)""" + # 提取Bearer token + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + + token = auth_header[7:] + auth_info = await self.token_verifier.verify_token(token) + + if not auth_info: + return None + + # DPoP验证(可选,阶段4实现) + verifier = dpop_verifier or self.dpop_verifier + if auth_info.cnf and auth_info.cnf.get("jkt") and verifier: + dpop_proof = request.headers.get("DPoP") + if not dpop_proof: + return None # DPoP-bound token必须包含DPoP证明 + + dpop_info = await verifier.verify_proof( + dpop_proof=dpop_proof, + method=request.method, + uri=str(request.url), + credential=token + ) + + if dpop_info.jwk_thumbprint != auth_info.cnf["jkt"]: + return None # 公钥不匹配 + + return auth_info + + # API Key 认证方案约定(方案 A):优先 X-API-Key header;可选 Authorization: Bearer (标准 scheme); + # 不解析非标准 ApiKey scheme;不在 token 内加前缀,由验证器顺序与 valid_keys 区分。 + class APIKeyVerifier(CredentialVerifier): + """API Key验证器""" + async def verify( + self, + request: Request, + dpop_verifier: DPoPProofVerifier | None = None + ) -> AuthInfo | None: + """验证API Key:优先 X-API-Key,回退 Bearer 并在 valid_keys 中查找""" + api_key = request.headers.get("X-API-Key") # 或 _get_header_ignore_case(request, "x-api-key") + if not api_key: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.strip().lower().startswith("bearer "): + bearer_token = auth_header[7:].strip() # len("Bearer ") + if bearer_token in self._valid_keys: + api_key = bearer_token + if not api_key or api_key not in self._valid_keys: + return None + + # ... 构造 AuthInfo/AccessToken + + # DPoP验证(可选,阶段4实现) + if dpop_verifier: + dpop_proof = request.headers.get("DPoP") + if dpop_proof: + await dpop_verifier.verify_proof(...) + + return auth_info + ``` + +#### 3.3.5 协议特定的元数据端点 + +**文件**: `src/mcp/server/auth/routes.py`(新增函数) + +**优先级**: 🟢 低 + +**改造内容**: +1. **新增协议元数据端点创建函数** + ```python + def create_protocol_metadata_routes( + protocol_id: str, + metadata: AuthProtocolMetadata + ) -> list[Route]: + """创建协议特定的元数据发现端点""" + # 例如: /.well-known/api-key-metadata + path = f"/.well-known/{protocol_id}-metadata" + handler = ProtocolMetadataHandler(metadata) + + return [ + Route( + path, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=["GET", "OPTIONS"], + ) + ] + ``` + +### 3.4 DPoP抽象组件(通用安全增强,可选) + +#### 3.4.1 客户端DPoP组件 + +**文件**: `src/mcp/client/auth/dpop.py`(新建) + +**优先级**: 🟢 低(可选安全增强) + +**改造内容**: +1. **DPoP证明生成器** + ```python + class DPoPProofGenerator(Protocol): + """DPoP证明生成器接口(协议无关)""" + def generate_proof( + self, + method: str, + uri: str, + credential: str | None = None, + nonce: str | None = None + ) -> str: ... + + def get_public_key_jwk(self) -> dict[str, Any]: ... + + class DPoPProofGeneratorImpl: + """DPoP证明生成器实现""" + def __init__(self, key_pair: DPoPKeyPair): + self.key_pair = key_pair + + def generate_proof(...) -> str: + # 实现DPoP JWT生成 + ``` + +2. **DPoP密钥对模型** + ```python + class DPoPKeyPair(BaseModel): + """DPoP密钥对(协议无关)""" + private_key_pem: str + public_key_jwk: dict[str, Any] + key_id: str | None = None + algorithm: str = "ES256" + + @classmethod + def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": + # 生成密钥对 + ``` + +3. **DPoP存储接口** + ```python + class DPoPStorage(Protocol): + """DPoP密钥对存储接口(协议无关)""" + async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: ... + async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: ... + ``` + +#### 3.4.2 服务器端DPoP组件 + +**文件**: `src/mcp/server/auth/dpop.py`(新建) + +**优先级**: 🟢 低(可选安全增强) + +**改造内容**: +1. **DPoP证明验证器** + ```python + class DPoPProofVerifier(Protocol): + """DPoP证明验证器接口(协议无关)""" + async def verify_proof( + self, + dpop_proof: str, + method: str, + uri: str, + credential: str | None = None + ) -> DPoPProofInfo: ... + + class DPoPProofVerifierImpl: + """DPoP证明验证器实现""" + def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): + self.allowed_algorithms = allowed_algorithms + self.jti_cache: dict[str, int] = {} + + async def verify_proof(...) -> DPoPProofInfo: + # 实现DPoP证明验证 + # 包含重放保护 + ``` + +### 3.5 新增文件和模块 + +#### 3.5.1 协议抽象和接口 + +**新建文件**: `src/mcp/client/auth/protocol.py` +- `AuthProtocol`基础协议接口(必需方法) +- `ClientRegisterableProtocol`扩展接口(可选) +- `DPoPEnabledProtocol`扩展接口(可选,阶段4) +- `AuthProtocolMetadata`模型(或从shared导入) + +**新建文件**: `src/mcp/client/auth/registry.py` +- `AuthProtocolRegistry`类 +- 协议选择逻辑 + +#### 3.5.2 多协议提供者 + +**新建文件**: `src/mcp/client/auth/multi_protocol.py` +- `MultiProtocolAuthProvider`类 +- 协议发现和选择逻辑 +- 凭证管理 + +#### 3.5.3 OAuth协议实现 + +**新建文件**: `src/mcp/client/auth/protocols/oauth2.py` +- `OAuth2Protocol`类(实现`AuthProtocol`) +- 将现有`OAuthClientProvider`逻辑迁移到这里 +- 可选:实现`DPoPEnabledProtocol`扩展接口(阶段4) + +#### 3.5.4 服务器端验证器 + +**新建文件**: `src/mcp/server/auth/verifiers.py` +- `CredentialVerifier`协议接口 +- `OAuthTokenVerifier`(现有TokenVerifier的适配) +- `APIKeyVerifier` +- `MultiProtocolAuthBackend` +- Mutual TLS 验证器(若实现):从 TLS 连接/握手上下文中读取客户端证书并校验,不解析 HTTP Authorization 头 + +**新建文件**: `src/mcp/server/auth/handlers/discovery.py` +- `AuthorizationServersDiscoveryHandler`类 +- `ProtocolMetadataHandler`类 + +#### 3.5.5 DPoP组件 + +**新建文件**: `src/mcp/client/auth/dpop.py` +- `DPoPProofGenerator`接口和实现 +- `DPoPKeyPair`模型 +- `DPoPStorage`接口 + +**新建文件**: `src/mcp/server/auth/dpop.py` +- `DPoPProofVerifier`接口和实现 +- `DPoPProofInfo`模型 +- 重放保护逻辑 + +## 四、改造优先级和依赖关系 + +### 4.1 高优先级(必须首先实现) + +#### 阶段1:基础设施(1-2周) +1. **数据模型扩展**(3.1) + - `AuthProtocolMetadata`模型 + - `ProtectedResourceMetadata`扩展 + - `AuthCredentials`基类 + - 依赖:无 + +2. **协议抽象接口**(3.1.3) + - `AuthProtocol`接口定义 + - `CredentialVerifier`接口定义 + - 依赖:数据模型 + +3. **WWW-Authenticate头扩展**(3.2.1, 3.3.3) + - 客户端解析扩展 + - 服务器端构建扩展 + - 依赖:数据模型 + +### 4.2 中优先级(核心功能) + +#### 阶段2:核心功能(2-3周) +4. **协议发现机制**(3.2.2, 3.3.2) + - 统一能力发现端点 + - 协议特定的元数据发现 + - 依赖:数据模型、WWW-Authenticate扩展 + +5. **协议注册和选择**(3.2.3) + - 协议注册表 + - 协议选择逻辑 + - 依赖:协议抽象接口 + +6. **多协议提供者**(3.2.4, 3.5.2) + - `MultiProtocolAuthProvider` + - 协议切换机制 + - 依赖:协议注册、协议发现 + +7. **请求准备方法**(3.2.5) + - 协议特定的认证信息添加 + - 依赖:多协议提供者 + - 注意:DPoP集成是可选功能,在阶段4实现 + +8. **认证后端重构**(3.3.4) + - `MultiProtocolAuthBackend` + - 协议特定的验证器 + - 依赖:协议抽象接口 + - 注意:DPoP验证是可选功能,在阶段4实现 + +### 4.3 低优先级(向后兼容和优化) + +#### 阶段3:适配和优化(1-2周) +9. **OAuth适配**(3.2.4) + - `OAuth2Protocol`实现 + - `OAuthClientProvider`向后兼容包装 + - 依赖:多协议提供者 + +10. **PRM端点扩展**(3.3.1) + - 扩展`create_protected_resource_routes()` + - 依赖:数据模型 + +11. **凭证存储扩展**(3.2.6) + - `TokenStorage`协议扩展 + - 凭证序列化/反序列化 + - 依赖:凭证模型 + +#### 阶段4:可选安全增强(可选,1-2周) +12. **DPoP组件实现**(3.4,可选) + - DPoP证明生成和验证 + - DPoP存储 + - DPoP协议扩展接口实现 + - 依赖:协议抽象接口 + - 注意:这是可选功能,可以跳过 + +### 4.4 依赖关系图 + +```mermaid +graph TD + A[数据模型扩展] --> B[协议抽象接口] + A --> C[WWW-Authenticate扩展] + B --> D[协议注册表] + B --> E[多协议提供者] + C --> F[协议发现机制] + D --> E + F --> E + E --> G[请求准备方法] + B --> H[认证后端] + E --> J[OAuth适配] + A --> K[凭证存储扩展] + I[DPoP组件-可选] -.-> G + I -.-> H +``` + +## 五、实施步骤 + +### 5.1 阶段1:基础设施(Week 1-2) + +**目标**:建立多协议支持的基础架构 + +**任务清单**: +- [ ] 实现`AuthProtocolMetadata`模型 +- [ ] 扩展`ProtectedResourceMetadata`添加`mcp_*`字段 +- [ ] 实现`AuthCredentials`基类和具体凭证类型 +- [ ] 定义`AuthProtocol`协议接口 +- [ ] 定义`CredentialVerifier`协议接口 +- [ ] 扩展WWW-Authenticate头解析(客户端) +- [ ] 扩展WWW-Authenticate头构建(服务器端) +- [ ] 编写单元测试 + +**验收标准**: +- 数据模型可以序列化/反序列化 +- WWW-Authenticate头可以正确解析和构建 +- 所有测试通过 + +**本阶段测试方案**: +- **单元/回归**:数据模型(`ProtectedResourceMetadata` 仅含 `resource`+`authorization_servers` 时校验 `mcp_auth_protocols`/`mcp_default_auth_protocol` 自动填充;`AuthProtocolMetadata`、`AuthCredentials`/`OAuthCredentials`/`APIKeyCredentials` 序列化与必填字段);客户端 `extract_field_from_www_auth` 不传 `auth_scheme` 时行为与改前一致,`extract_auth_protocols_from_www_auth`、`extract_default_protocol_from_www_auth`、`extract_protocol_preferences_from_www_auth` 解析正确;服务端 `RequireAuthMiddleware` 仅传 `(app, required_scopes, resource_metadata_url)` 时 WWW-Authenticate 仍为 Bearer,且含 `error`/`error_description`/可选 `resource_metadata`。**执行**:`uv run pytest tests/client/test_auth.py tests/server/auth/middleware/test_bearer_auth.py -v` +- **集成/交互**:使用 simple-auth(AS+RS)与 simple-auth-client 跑通 401→PRM/AS 发现→OAuth→Token→MCP 会话→`list`/`call get_time`/`quit`。详细步骤与检查项见 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md`。**脚本**:`./scripts/run_phase1_oauth2_integration_test.sh`(启动 AS/RS 并运行客户端,人工完成浏览器 OAuth 与 list/call/quit)。 + +### 5.2 阶段2:核心功能(Week 3-5) + +**目标**:实现多协议发现、选择和认证流程 + +**任务清单**: +- [ ] 实现统一能力发现端点(服务器端) +- [ ] 实现协议发现逻辑(客户端) +- [ ] 实现协议注册表 +- [ ] 实现协议选择逻辑 +- [ ] 实现`MultiProtocolAuthProvider` +- [ ] 实现协议特定的请求准备(不含DPoP,DPoP在阶段4) +- [ ] 实现`MultiProtocolAuthBackend` +- [ ] 实现OAuth验证器适配(不含DPoP验证,DPoP在阶段4) +- [ ] 编写集成测试 + +**验收标准**: +- 可以发现服务器支持的协议 +- 可以根据优先级选择协议 +- 可以执行多协议认证流程 +- 所有测试通过 + +**本阶段测试方案**: +- **单元**:协议注册表(注册/获取协议类、`select_protocol` 在给定 `available_protocols`/`default_protocol`/`preferences` 下的选择结果);协议发现(统一发现端点返回的解析、回退到 PRM/协议特定 well-known 的逻辑,若本阶段实现);`MultiProtocolAuthProvider`(在 mock 协议与存储下 `async_auth_flow` 的初始化、凭证校验、请求准备不含 DPoP 分支);服务端验证器与 `MultiProtocolAuthBackend`(按协议 ID 选择验证器,401/403 时 WWW-Authenticate 含 `auth_protocols`/`default_protocol`/`protocol_preferences` 当配置了这些参数时)。**集成**:若本阶段暴露统一发现端点,客户端请求 `/.well-known/authorization_servers` 得到协议列表,服务器 401 头含协议扩展字段时客户端能解析;回归:再次运行阶段1的 OAuth2 单元测试及 simple-auth + simple-auth-client 交互。**执行**:新增 `tests/client/auth/test_registry.py`、`tests/client/auth/test_multi_protocol_provider.py`(或等价),服务端 `tests/server/auth/test_verifiers.py` 或扩展现有 middleware 测试,集成可复用 `run_phase1_oauth2_integration_test.sh` 做回归。 + +#### 5.2.1 阶段2 新协议支持测试(可选协议选择与交互) + +阶段2 交付多协议发现、选择与认证流程后,需验证**能够选择非 OAuth 的认证协议并完成 MCP 交互**。使用**基于 simple-auth 与 simple-auth-client 的测试用服务端与客户端**,必要时对二者进行改造。新协议需覆盖 **API Key** 与 **Mutual TLS** 两种方式。 + +- **目标**:服务器同时或单独支持至少两种非 OAuth 协议(API Key、Mutual TLS),在 PRM 或 401 头中声明 `auth_protocols`/`default_protocol`(或 `protocol_preferences`);客户端能够发现协议列表、选择上述协议之一、使用对应凭证完成认证并与 MCP 服务交互(list tools、call tool、quit);API Key 与 Mutual TLS 两种路径均需覆盖。 +- **测试用服务端(基于 simple-auth)**:在 `examples/servers/` 下新增项目(如 `simple-auth-multiprotocol`),以 `examples/servers/simple-auth` 为蓝本复制并改造。在 RS 上增加对 API Key 与 Mutual TLS 两种非 OAuth 协议的支持;使用阶段2 的 `MultiProtocolAuthBackend`、API Key 验证器与 Mutual TLS 验证器(或占位实现);PRM 与 `RequireAuthMiddleware` 传入 `auth_protocols`(含 oauth2、api_key、mutual_tls)、`default_protocol`(及可选 `protocol_preferences`);统一发现端点若在本阶段实现则返回包含 oauth2、api_key、mutual_tls 的协议列表。保留原有 OAuth(AS+RS)能力。**验收**:RS 的 401 响应 WWW-Authenticate 含 `auth_protocols`(如 `oauth2 api_key mutual_tls`);合法 API Key 与合法客户端证书(Mutual TLS)的请求均能通过认证并访问 MCP 端点(如 list_tools、get_time)。 +- **测试用客户端(基于 simple-auth-client)**:在 `examples/clients/` 下新增项目(如 `simple-auth-multiprotocol-client`),以 `examples/clients/simple-auth-client` 为蓝本复制并改造。使用阶段2 的 `MultiProtocolAuthProvider` 与协议注册表;配置 API Key 与 Mutual TLS 协议实现及凭证(API Key 如环境变量或配置文件,Mutual TLS 如客户端证书/密钥路径);发现到服务器支持 api_key 或 mutual_tls 时能选择对应协议并完成认证(无需浏览器),建立 MCP 会话并支持 list/call/quit。**验收**:(1)能选择 API Key、携带 API Key 凭证发起请求、成功初始化会话并执行 list、call get_time、quit;(2)能选择 Mutual TLS、使用客户端证书发起请求、成功初始化会话并执行 list、call get_time、quit。 +- **测试场景与执行**:**API Key 路径**:启动多协议 RS(及可选 AS),启动多协议客户端并配置使用 API Key,执行 list→call get_time→quit,断言无认证错误且返回正确。**Mutual TLS 路径**:同一多协议 RS 启用 Mutual TLS 验证,启动多协议客户端并配置使用客户端证书,执行 list→call get_time→quit,断言无认证错误且返回正确。可选:编写 `scripts/run_phase2_multiprotocol_integration_test.sh`,支持通过参数或环境变量选择 API Key 或 Mutual TLS。原 simple-auth/simple-auth-client 仍用于 OAuth 回归;新示例仅用于阶段2 及之后的「新协议选择与交互」验证(API Key + Mutual TLS)。 + +### 5.3 阶段3:OAuth适配和优化(Week 6-7) + +**目标**:完成OAuth协议适配和向后兼容优化 + +**任务清单**: +- [ ] 实现`OAuth2Protocol`类 +- [ ] 将现有`OAuthClientProvider`逻辑迁移 +- [ ] 实现`OAuthClientProvider`向后兼容包装 +- [ ] 实现PRM端点扩展 +- [ ] 实现凭证存储扩展 +- [ ] 编写集成测试 + +**验收标准**: +- 现有OAuth代码无需修改即可工作 +- 所有测试通过 +- 向后兼容性验证通过 + +**本阶段测试方案**: +- **单元**:`OAuth2Protocol`(`authenticate`、`prepare_request`、`validate_credentials`、`discover_metadata` 在 mock 上下文下的行为,与现有 `OAuthClientProvider` 逻辑等价性如 discovery URL 顺序、token 交换参数);`OAuthClientProvider` 包装(对外 API 不变,内部委托 `MultiProtocolAuthProvider`+`OAuth2Protocol`,现有 `tests/client/test_auth.py` 中所有 OAuth 相关用例仍通过);TokenStorage 扩展(`get_auth_credentials`/`set_auth_credentials` 与现有 `get_tokens`/`set_tokens` 的适配器行为);PRM 端点扩展(`create_protected_resource_routes` 传入 `auth_protocols`/`default_protocol`/`protocol_preferences` 时 PRM JSON 与 401 头包含 MCP 扩展字段)。**集成**:再次运行 simple-auth + simple-auth-client 全流程(与阶段1相同步骤/脚本),确认 OAuth 仍为默认路径且行为一致;若有条件,同一 RS 同时支持 OAuth 与 API Key(或占位协议),客户端通过协议选择使用 OAuth,验证端到端多协议发现+OAuth 分支。**执行**:扩展现有 `tests/client/test_auth.py`,新增或扩展 `tests/server/auth/` 下 PRM/路由测试,集成继续使用 `scripts/run_phase1_oauth2_integration_test.sh` 及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md` 检查清单。 + +### 5.4 阶段4:可选安全增强(Week 8,可选) + +**目标**:实现DPoP作为可选安全增强组件 + +**任务清单**(可选,可以跳过): +- [ ] 实现DPoP证明生成器(`DPoPProofGenerator`) +- [ ] 实现DPoP证明验证器(`DPoPProofVerifier`) +- [ ] 实现DPoP存储接口(`DPoPStorage`) +- [ ] 实现`DPoPEnabledProtocol`扩展接口 +- [ ] 在`OAuth2Protocol`中实现`DPoPEnabledProtocol` +- [ ] 更新`MultiProtocolAuthProvider`的`_prepare_request`方法,集成DPoP +- [ ] 更新`OAuthTokenVerifier`和`APIKeyVerifier`,支持DPoP验证 +- [ ] 编写DPoP测试 + +**验收标准**: +- DPoP可以在OAuth和其他协议中使用 +- DPoP功能完全可选,不影响核心功能 +- 所有测试通过 + +**本阶段测试方案**: +- **单元**:DPoP 客户端(`DPoPProofGenerator` 生成 proof 的格式与算法,`DPoPStorage` 存取 key pair,`DPoPKeyPair.generate`);DPoP 服务端(`DPoPProofVerifier` 校验 proof、jti 重放保护、与 `OAuthTokenVerifier`/APIKey 验证器的组合);`DPoPEnabledProtocol`(`supports_dpop`、`get_dpop_proof_generator`、`initialize_dpop` 及在 `MultiProtocolAuthProvider._prepare_request` 中的调用当协议支持 DPoP 且启用时)。**集成**:OAuth+DPoP(启用 DPoP 的 OAuth 客户端对支持 DPoP 的 RS 发起请求,401 后带 DPoP proof 的 token 请求成功,若本阶段实现完整链路);回归:关闭 DPoP 时阶段1的 simple-auth+simple-auth-client 流程不变,所有既有 OAuth 单元与集成测试通过。**执行**:新增 `tests/client/auth/test_dpop.py`、`tests/server/auth/test_dpop.py`(或等价),集成可扩展现有示例或新增最小 DPoP 示例与脚本。 + +### 5.5 阶段5:新协议示例和文档(Week 9) + +**目标**:提供新协议实现示例和完整文档 + +**任务清单**: +- [ ] 实现API Key协议示例 +- [ ] 编写协议实现指南 +- [ ] 更新API文档 +- [ ] 编写迁移指南 +- [ ] 编写使用示例 +- [ ] 编写DPoP使用文档(如果阶段4完成) + +**验收标准**: +- 有完整的API Key协议实现示例 +- 文档清晰易懂 +- 开发者可以基于示例实现新协议 + +**本阶段测试方案**: +- **单元**:API Key 协议示例(协议实现满足 `AuthProtocol`,注册到注册表后能被选择并完成 `prepare_request`);文档与示例以文档评审与示例运行为主。**集成**:使用 API Key 示例客户端对支持 API Key 的示例服务完成一次认证与 MCP 调用(若提供示例);全回归:再次运行阶段1的 OAuth2 单元测试及 simple-auth+simple-auth-client 交互,确保文档与示例未引入回归。**执行**:示例目录下可增加 `tests/examples/` 或 README 中的「如何运行与验证」;主测试套件仍包含 `tests/client/test_auth.py` 与 `tests/server/auth/middleware/test_bearer_auth.py` 的完整运行。 + +## 六、向后兼容策略 + +### 6.1 数据模型兼容 + +- **保持RFC 9728必需字段不变**:`resource`和`authorization_servers`必须保持为必需字段 +- **`mcp_*`扩展字段为可选**:标准OAuth客户端可以忽略这些字段 +- **自动兼容处理**:如果`mcp_auth_protocols`为空,自动从`authorization_servers`创建OAuth协议元数据 + +### 6.2 API兼容 + +- **`OAuthClientProvider`保持现有API不变** +- **内部使用`OAuth2Protocol`和`MultiProtocolAuthProvider`** +- **现有代码无需修改即可工作** + +### 6.3 行为兼容 + +- **默认行为**:如果没有协议声明,使用OAuth 2.0 +- **现有OAuth流程保持不变** +- **新协议作为可选功能添加** + +## 七、测试策略 + +### 7.1 单元测试 + +各阶段具体单元测试范围、新增用例及执行命令见第五节 5.1~5.5 中各阶段的「本阶段测试方案」。 + +- 数据模型序列化/反序列化测试 +- 协议发现逻辑测试 +- 协议选择逻辑测试 +- WWW-Authenticate头解析/构建测试 +- DPoP证明生成和验证测试 + +### 7.2 集成测试 + +阶段1~3 的 OAuth 回归均以 simple-auth + simple-auth-client 交互测试为准,详见 5.1 本阶段测试方案及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md`;阶段2~5 的集成要点见对应小节。阶段2 完成后需增加新协议支持测试:基于 simple-auth 与 simple-auth-client 分别实现多协议测试服务端与客户端(见 5.2.1),验证可选择 **API Key** 与 **Mutual TLS** 两种非 OAuth 协议并完成 MCP 交互。 + +- 多协议认证流程测试 +- 协议切换测试 +- DPoP集成测试 +- 向后兼容性测试 + +### 7.3 端到端测试 + +多协议与 DPoP 端到端场景见 5.2~5.4 本阶段测试方案。 + +- 完整的多协议认证场景 +- OAuth + DPoP场景 +- API Key + DPoP场景 +- 协议降级场景 + +## 八、风险评估和缓解 + +### 8.1 技术风险 + +**风险1:向后兼容性破坏** +- **缓解**:保持现有API不变,内部使用适配器模式 +- **验证**:运行现有测试套件确保无回归 + +**风险2:性能影响** +- **缓解**:协议发现结果缓存,避免重复发现 +- **验证**:性能基准测试 + +**风险3:复杂度增加** +- **缓解**:清晰的抽象层次,良好的文档 +- **验证**:代码审查,架构评审 + +### 8.2 实施风险 + +**风险1:改造范围过大** +- **缓解**:分阶段实施,每个阶段都有可交付成果 +- **验证**:定期检查进度 + +**风险2:测试覆盖不足** +- **缓解**:每个阶段都有测试要求 +- **验证**:代码覆盖率检查 + +## 九、成功标准 + +### 9.1 功能完整性 + +- ✅ 支持OAuth 2.0协议(向后兼容) +- ✅ 支持至少一种新协议(如API Key) +- ✅ 支持DPoP作为可选安全增强 +- ✅ 协议自动发现和选择 +- ✅ 协议切换机制 + +### 9.2 代码质量 + +- ✅ 所有现有测试通过 +- ✅ 新功能测试覆盖率>80% +- ✅ 代码审查通过 +- ✅ 文档完整 + +### 9.3 向后兼容性 + +- ✅ 现有OAuth代码无需修改 +- ✅ 现有API保持不变 +- ✅ 现有行为保持一致 + +### 9.4 代码提交规范 + +本规范适用于本改造计划中每一项任务(TODO)完成后的提交,执行时须遵循: + +- 提交信息使用简洁英文(如 Add ... / Fix ...),仅描述本项改动。 +- 不包含 TODO 编号或内部任务引用。 +- 每次提交仅包含当前任务相关改动。 + +## 十、总结 + +本改造计划基于章节12.5和13的分析,结合DPoP抽象设计,提供了完整的多协议授权支持改造路线图。 + +**关键要点**: +1. **分阶段实施**:四个主要阶段(阶段4可选),每个阶段都有明确的交付物 +2. **向后兼容**:确保现有代码无需修改 +3. **协议抽象**:统一的接口,便于扩展 +4. **DPoP集成**:作为可选的通用安全增强组件(阶段4,可选) +5. **渐进式迁移**:可以逐步启用新功能 +6. **最小化接口**:基础接口只包含必需方法,可选功能通过扩展接口实现 + +**预计时间**: +- 核心功能:7周(阶段1-3) +- 完整功能(含DPoP):9周(阶段1-5,阶段4可选) + +**团队要求**: +- 熟悉OAuth 2.0协议 +- 熟悉Python异步编程 +- 熟悉HTTP协议和RESTful API设计 +- 熟悉测试驱动开发 From 6aa7e7709fe34163f0c1815c6610b004aa64742b Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 29 Jan 2026 23:39:10 +0800 Subject: [PATCH 38/64] docs(auth): update auth-analysis and multi-protocol-refactoring-plan --- src/mcp/client/auth/auth-analysis.md | 25 +++++++++++-------- .../auth/multi-protocol-refactoring-plan.md | 23 ++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/mcp/client/auth/auth-analysis.md b/src/mcp/client/auth/auth-analysis.md index e78fdcba9..7c44cf24a 100644 --- a/src/mcp/client/auth/auth-analysis.md +++ b/src/mcp/client/auth/auth-analysis.md @@ -1393,14 +1393,17 @@ class AuthProtocol(Protocol): # 4. 准备请求认证信息 ``` -2. **OAuthClientProvider 适配** - - 将 `OAuthClientProvider` 改为 `OAuth2Protocol` 实现 - - 保持现有 API 不变(向后兼容) - - 内部使用 `MultiProtocolAuthProvider` +2. **OAuthClientProvider 保持为 OAuth 逻辑的唯一实现(最大程度复用)** + - **不**将 OAuth 发现/注册/授权码/令牌交换逻辑迁出到 OAuth2Protocol + - 新增 `run_authentication(self, http_client, *, resource_metadata_url=None, scope_from_www_auth=None)`:使用 `http_client` 执行完整 OAuth 流程(PRM/ASM 发现、scope 选择、注册或 CIMD、授权码 + 令牌交换),与现有 401 分支行为一致 + - 现有 `async_auth_flow` 的 401 分支保持不变;多协议路径下由 OAuth2Protocol 调用 `run_authentication(http_client)` 复用同一套逻辑 -3. **OAuthContext 扩展** - - 支持多协议上下文 - - 协议特定的元数据存储 +3. **OAuth2Protocol 作为薄适配层** + - `OAuth2Protocol` 实现 `AuthProtocol`,**不**重复实现 OAuth 发现/注册/编排 + - `authenticate(context)`:从 `AuthContext` 与自身配置组装 OAuth 所需上下文,构造 `OAuthClientProvider`,写入已发现的 PRM、protocol_version、scope_from_www_auth 等,调用 `provider.run_authentication(context.http_client, ...)`,从 provider 的 current_tokens 转为 `OAuthCredentials` 并返回 + +4. **OAuthContext 扩展** + - 支持由多协议层传入已发现的 `protected_resource_metadata`、`protocol_version`、`scope_from_www_auth`(供 `run_authentication` 使用) #### 13.2.5 请求认证信息准备 @@ -1625,8 +1628,8 @@ class AuthProtocol(Protocol): #### 13.4.3 OAuth 协议实现 **新建文件**: `src/mcp/client/auth/protocols/oauth2.py` -- `OAuth2Protocol` 类(实现 `AuthProtocol`) -- 将现有 `OAuthClientProvider` 逻辑迁移到这里 +- `OAuth2Protocol` 类(实现 `AuthProtocol`),**薄适配层** +- **不**迁移 OAuth 逻辑到此文件;`authenticate(context)` 内构造 `OAuthClientProvider`、填充上下文后调用 `provider.run_authentication(context.http_client)`,复用现有 `oauth2.py` 实现 #### 13.4.4 服务器端验证器 @@ -1691,8 +1694,8 @@ class AuthProtocol(Protocol): - 如果 `mcp_auth_protocols` 为空,自动从 `authorization_servers` 创建 OAuth 协议元数据 #### API 兼容 -- `OAuthClientProvider` 保持现有 API 不变 -- 内部使用 `OAuth2Protocol` 和 `MultiProtocolAuthProvider` +- `OAuthClientProvider` 保持现有 API 不变,并新增 `run_authentication(http_client, ...)` 供多协议路径调用 +- 多协议场景下 `OAuth2Protocol` 内部委托 `OAuthClientProvider.run_authentication`,不重复实现 OAuth 流程 - 现有代码无需修改即可工作 #### 行为兼容 diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md index 87422522a..cfc42fa2c 100644 --- a/src/mcp/client/auth/multi-protocol-refactoring-plan.md +++ b/src/mcp/client/auth/multi-protocol-refactoring-plan.md @@ -434,10 +434,9 @@ DPoP作为独立的通用组件,协议可以选择性使用: await self._handle_403_response(response, request) ``` -2. **OAuthClientProvider适配** - - 将`OAuthClientProvider`改为`OAuth2Protocol`实现 - - 保持现有API不变(向后兼容) - - 内部使用`MultiProtocolAuthProvider` +2. **OAuthClientProvider 保持为 OAuth 逻辑唯一实现(最大程度复用)** + - **不**将 OAuth 逻辑迁出到 OAuth2Protocol;新增 `run_authentication(http_client, ...)` 供多协议路径调用 + - 保持现有 API 不变(向后兼容);OAuth2Protocol 为薄适配层,内部委托 `OAuthClientProvider.run_authentication` 3. **协议上下文扩展** ```python @@ -940,8 +939,8 @@ DPoP作为独立的通用组件,协议可以选择性使用: #### 3.5.3 OAuth协议实现 **新建文件**: `src/mcp/client/auth/protocols/oauth2.py` -- `OAuth2Protocol`类(实现`AuthProtocol`) -- 将现有`OAuthClientProvider`逻辑迁移到这里 +- `OAuth2Protocol`类(实现`AuthProtocol`),**薄适配层** +- **不**迁移 OAuth 逻辑到此文件;`authenticate(context)` 内构造 `OAuthClientProvider`、填充上下文后调用 `provider.run_authentication(context.http_client)` 复用现有实现 - 可选:实现`DPoPEnabledProtocol`扩展接口(阶段4) #### 3.5.4 服务器端验证器 @@ -1126,9 +1125,9 @@ graph TD **目标**:完成OAuth协议适配和向后兼容优化 **任务清单**: -- [ ] 实现`OAuth2Protocol`类 -- [ ] 将现有`OAuthClientProvider`逻辑迁移 -- [ ] 实现`OAuthClientProvider`向后兼容包装 +- [ ] 实现`OAuth2Protocol`类(薄适配层,委托 `OAuthClientProvider.run_authentication`) +- [ ] 在`OAuthClientProvider`中新增`run_authentication(http_client, ...)`,复用现有 401 分支逻辑 +- [ ] 保持`OAuthClientProvider`现有 API 与行为不变(向后兼容) - [ ] 实现PRM端点扩展 - [ ] 实现凭证存储扩展 - [ ] 编写集成测试 @@ -1139,7 +1138,7 @@ graph TD - 向后兼容性验证通过 **本阶段测试方案**: -- **单元**:`OAuth2Protocol`(`authenticate`、`prepare_request`、`validate_credentials`、`discover_metadata` 在 mock 上下文下的行为,与现有 `OAuthClientProvider` 逻辑等价性如 discovery URL 顺序、token 交换参数);`OAuthClientProvider` 包装(对外 API 不变,内部委托 `MultiProtocolAuthProvider`+`OAuth2Protocol`,现有 `tests/client/test_auth.py` 中所有 OAuth 相关用例仍通过);TokenStorage 扩展(`get_auth_credentials`/`set_auth_credentials` 与现有 `get_tokens`/`set_tokens` 的适配器行为);PRM 端点扩展(`create_protected_resource_routes` 传入 `auth_protocols`/`default_protocol`/`protocol_preferences` 时 PRM JSON 与 401 头包含 MCP 扩展字段)。**集成**:再次运行 simple-auth + simple-auth-client 全流程(与阶段1相同步骤/脚本),确认 OAuth 仍为默认路径且行为一致;若有条件,同一 RS 同时支持 OAuth 与 API Key(或占位协议),客户端通过协议选择使用 OAuth,验证端到端多协议发现+OAuth 分支。**执行**:扩展现有 `tests/client/test_auth.py`,新增或扩展 `tests/server/auth/` 下 PRM/路由测试,集成继续使用 `scripts/run_phase1_oauth2_integration_test.sh` 及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md` 检查清单。 +- **单元**:`OAuth2Protocol`(`authenticate` 委托 `OAuthClientProvider.run_authentication`、`prepare_request`、`validate_credentials`、`discover_metadata` 在 mock 上下文下的行为);`OAuthClientProvider.run_authentication`(与现有 401 分支行为一致);`OAuthClientProvider` 对外 API 不变,现有 `tests/client/test_auth.py` 中所有 OAuth 相关用例仍通过;TokenStorage 扩展;PRM 端点扩展。**集成**:再次运行 simple-auth + simple-auth-client 全流程,确认 OAuth 仍为默认路径且行为一致;若有条件,同一 RS 同时支持 OAuth 与 API Key,客户端通过协议选择使用 OAuth,验证端到端多协议发现+OAuth 分支。**执行**:扩展现有 `tests/client/test_auth.py`,新增或扩展 `tests/server/auth/` 下 PRM/路由测试,集成继续使用 `scripts/run_phase1_oauth2_integration_test.sh` 及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md` 检查清单。 ### 5.4 阶段4:可选安全增强(Week 8,可选) @@ -1193,8 +1192,8 @@ graph TD ### 6.2 API兼容 -- **`OAuthClientProvider`保持现有API不变** -- **内部使用`OAuth2Protocol`和`MultiProtocolAuthProvider`** +- **`OAuthClientProvider`保持现有API不变**,并新增 `run_authentication(http_client, ...)` 供多协议路径调用 +- **OAuth2Protocol 为薄适配层**,内部委托 `OAuthClientProvider.run_authentication`,不重复实现 OAuth 流程 - **现有代码无需修改即可工作** ### 6.3 行为兼容 From b68a193abab646f05745074fdd5719236cd9ba88 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sun, 1 Feb 2026 22:00:33 +0800 Subject: [PATCH 39/64] Add API Key, DPoP, and Mutual TLS running instructions to multiprotocol examples --- examples/README.md | 22 +++++++- .../README.md | 44 ++++++++++++++++ .../simple-auth-multiprotocol/README.md | 51 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 5ed4dd55f..2959595c2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,25 @@ # Python SDK Examples -This folders aims to provide simple examples of using the Python SDK. Please refer to the +This folder aims to provide simple examples of using the Python SDK. Please refer to the [servers repository](https://github.com/modelcontextprotocol/servers) for real-world servers. + +## Multi-protocol auth + +- **Server**: [simple-auth-multiprotocol](servers/simple-auth-multiprotocol/) — RS with OAuth, API Key, DPoP, and Mutual TLS (placeholder). + +**API Key** + +- Use `MCP_API_KEY` on the client; start RS with `--api-keys=...` (no AS required). +- One-command test (from repo root): `MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` + +**OAuth + DPoP** + +- Start AS and RS with `--dpop-enabled`; client: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1`. +- One-command test (from repo root): `./scripts/run_phase4_dpop_integration_test.sh` (use `MCP_SKIP_OAUTH=1` to skip manual OAuth step). + +**Mutual TLS (placeholder)** + +- mTLS is a placeholder (no client cert validation). Script: `MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` + +**Client**: [simple-auth-multiprotocol-client](clients/simple-auth-multiprotocol-client/) — supports API Key (`MCP_API_KEY`), OAuth+DPoP (`MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1`), and mTLS placeholder. diff --git a/examples/clients/simple-auth-multiprotocol-client/README.md b/examples/clients/simple-auth-multiprotocol-client/README.md index 3862c415d..7c5020793 100644 --- a/examples/clients/simple-auth-multiprotocol-client/README.md +++ b/examples/clients/simple-auth-multiprotocol-client/README.md @@ -12,6 +12,50 @@ MCP client example using **MultiProtocolAuthProvider** with **API Key** and **Mu 2. From this directory: `uv run mcp-simple-auth-multiprotocol-client` or `uv run python -m mcp_simple_auth_multiprotocol_client`. 3. Optional: `MCP_SERVER_URL=http://localhost:8002/mcp` to override server URL. +## Running with API Key + +When the server supports API Key (e.g. `simple-auth-multiprotocol` with `--api-keys`), set: + +- **`MCP_API_KEY`** – your API key (e.g. `demo-api-key-12345`). The client sends it as `X-API-Key`. +- **`MCP_SERVER_URL`** – optional; default is `http://localhost:8002/mcp` when using the default client config. + +Example (server on port 8002, no OAuth/AS required): + +```bash +MCP_SERVER_URL=http://localhost:8002/mcp MCP_API_KEY=demo-api-key-12345 uv run mcp-simple-auth-multiprotocol-client +``` + +**One-command test** from repo root: +`MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +starts the resource server and this client with API Key; at `mcp>` run `list`, `call get_time {}`, `quit`. + +## Running with OAuth + DPoP + +When the server has DPoP enabled (`--dpop-enabled`), use OAuth and DPoP together: + +- **`MCP_USE_OAUTH=1`** – enable OAuth (required for DPoP). +- **`MCP_DPOP_ENABLED=1`** – send DPoP-bound access tokens (DPoP proof in each request). + +Example (server on port 8002 with DPoP, AS on 9000): + +```bash +MCP_SERVER_URL=http://localhost:8002/mcp MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1 uv run mcp-simple-auth-multiprotocol-client +``` + +Complete OAuth in the browser; then at `mcp>` run `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". + +**One-command test** from repo root: +`./scripts/run_phase4_dpop_integration_test.sh` — starts AS and RS with DPoP, then runs this client (OAuth+DPoP). Use `MCP_SKIP_OAUTH=1` to run only the automated curl tests and skip the manual client step. + +## Running with Mutual TLS (placeholder) + +Mutual TLS is a **placeholder** in this example: the client registers the `mutual_tls` protocol but does **not** perform client certificate authentication. Selecting mTLS will show a "not implemented" style message. + +- **`MCP_PHASE2_PROTOCOL=mutual_tls`** (with the phase2 script) runs this client in mTLS mode; the client will start but mTLS auth is not implemented. + +**One-command test** from repo root: +`MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` + ## Commands - `list` – list tools diff --git a/examples/servers/simple-auth-multiprotocol/README.md b/examples/servers/simple-auth-multiprotocol/README.md index 5e3c2947d..4504e08ff 100644 --- a/examples/servers/simple-auth-multiprotocol/README.md +++ b/examples/servers/simple-auth-multiprotocol/README.md @@ -19,11 +19,62 @@ MCP Resource Server example that supports **OAuth 2.0** (introspection), **API K - API Key: set header `X-API-Key: demo-api-key-12345` or `Authorization: Bearer demo-api-key-12345` (default key). Custom keys: `--api-keys=key1,key2`. +## Running with API Key only + +You can run the Resource Server **without** the Authorization Server when using API Key authentication: + +1. **Start the Resource Server** (from this directory): + ```bash + uv run mcp-simple-auth-multiprotocol-rs --port=8002 --api-keys=demo-api-key-12345 + ``` + +2. **Run the client** from `examples/clients/simple-auth-multiprotocol-client`: + ```bash + MCP_SERVER_URL=http://localhost:8002/mcp MCP_API_KEY=demo-api-key-12345 uv run mcp-simple-auth-multiprotocol-client + ``` + +3. At the `mcp>` prompt, run `list`, `call get_time {}`, then `quit`. + +**One-command verification** (from repo root): +`MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +This starts the RS, then the client with API Key; complete the session with `list`, `call get_time {}`, `quit`. + +## Running with DPoP (OAuth + DPoP) + +DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds the access token to a client-held key. Use it together with OAuth. + +1. **Start the Authorization Server** (from `examples/servers/simple-auth`): + `uv run mcp-simple-auth-as --port=9000` + +2. **Start this Resource Server with DPoP enabled** (from this directory): + ```bash + uv run mcp-simple-auth-multiprotocol-rs --port=8002 --auth-server=http://localhost:9000 --api-keys=demo-api-key-12345 --dpop-enabled + ``` + +3. **Run the client** with OAuth and DPoP from `examples/clients/simple-auth-multiprotocol-client`: + ```bash + MCP_SERVER_URL=http://localhost:8002/mcp MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1 uv run mcp-simple-auth-multiprotocol-client + ``` + Complete OAuth in the browser, then at `mcp>` run `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". + +**One-command verification** (from repo root): +`./scripts/run_phase4_dpop_integration_test.sh` — starts AS and RS (with `--dpop-enabled`), runs automated DPoP tests, then optionally the OAuth+DPoP client (use `MCP_SKIP_OAUTH=1` to skip the manual OAuth step). + +## Running with Mutual TLS (placeholder) + +Mutual TLS is a **placeholder** in this example: the server accepts the `mutual_tls` protocol in PRM/discovery but does **not** perform client certificate validation. Selecting mTLS in the client will show a "not implemented" style message. + +- **Server**: No extra flags; `auth_protocols` already includes `mutual_tls`. +- **Client** (from repo root): + `MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` + The client will start but mTLS authentication is not implemented in this example. + ## Options - `--port`: RS port (default 8002). - `--auth-server`: AS URL (default http://localhost:9000). - `--api-keys`: Comma-separated valid API keys (default demo-api-key-12345). - `--oauth-strict`: Enable RFC 8707 resource validation. +- `--dpop-enabled`: Enable DPoP proof verification (RFC 9449); use with OAuth. Mutual TLS is a placeholder (no client certificate validation). From 4445b5196522ebfbbed0a27ba0bcb60ea701ae94 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sun, 1 Feb 2026 23:31:23 +0800 Subject: [PATCH 40/64] Add authorization design docs (multi-protocol) --- docs/authorization-multiprotocol.md | 512 ++++++++++++++++++++++++++++ docs/authorization.md | 15 +- 2 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 docs/authorization-multiprotocol.md diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md new file mode 100644 index 000000000..6430b0029 --- /dev/null +++ b/docs/authorization-multiprotocol.md @@ -0,0 +1,512 @@ +# Authorization: Multi-Protocol Extension + +This document extends the [Authorization](authorization.md) topic with design purpose, implementation logic, usage and integration, test examples, and limitations for multi-protocol auth in the MCP Python SDK. + +**References (RFCs and specs):** + +- [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0 Authorization Framework +- [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) — Bearer Token Usage +- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) — OAuth 2.0 Authorization Server Metadata +- [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) — OAuth 2.0 Protected Resource Metadata +- [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) — DPoP (Demonstrating Proof-of-Possession) +- [MCP Specification](https://spec.modelcontextprotocol.io/) — Model Context Protocol (authorization and transports) + +--- + +## 1. Design purpose + +### 1.1 Goals + +The multi-protocol authorization extension aims to: + +1. **Support multiple auth schemes** — Allow a single MCP resource server to accept OAuth 2.0, API Key, and (optionally) Mutual TLS or other protocols, so different clients can use the most appropriate method (e.g. API Key for automation, OAuth for user-delegated access). +2. **Preserve backward compatibility** — Existing OAuth-only clients and servers (e.g. `OAuthClientProvider`, `simple-auth` / `simple-auth-client`) continue to work without change; multi-protocol is additive. +3. **Unify discovery and selection** — The server declares supported protocols and optional default/preferences; the client discovers them via standard metadata (PRM, WWW-Authenticate) and selects one without hard-coding a single scheme. +4. **Enable optional DPoP** — When using OAuth, the client can bind the access token to a proof-of-possession key (DPoP, RFC 9449) to reduce token theft and replay risk. + +### 1.2 Non-goals + +- **Replacing OAuth** — OAuth remains the primary protocol for user-delegated access; API Key and mTLS are alternatives for machine or certificate-based auth. +- **Implementing full mTLS in examples** — The current examples use an mTLS *placeholder* (protocol declared, no real client certificate validation) to show protocol selection only. +- **Defining new HTTP auth schemes** — We use existing schemes (Bearer, DPoP, X-API-Key) and extend 401/403 response parameters (e.g. `auth_protocols`, `resource_metadata`) as in RFC 9728 and MCP conventions. + +--- + +## 2. Code implementation logic + +### 2.1 Authorization service discovery + +Discovery answers: *Which auth protocols does this resource support, and where is their metadata?* + +**Sources (in order of use):** + +1. **WWW-Authenticate on 401** — The resource server may include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, `protocol_preferences` (MCP extensions). See RFC 6750 (Bearer) and RFC 9728 (resource metadata). +2. **Protected Resource Metadata (PRM)** — RFC 9728 defines `/.well-known/oauth-protected-resource` (optionally with path). PRM JSON includes `authorization_servers`, and the SDK extends it with `mcp_auth_protocols`, `mcp_default_auth_protocol`, `mcp_auth_protocol_preferences`. +3. **Unified discovery endpoint** — `/.well-known/authorization_servers` returns a list of protocol metadata (MCP-style). Clients try this first; if it fails or returns no protocols, they fall back to PRM’s `mcp_auth_protocols`. + +**Client-side logic (high level):** + +- On 401, extract `resource_metadata` from WWW-Authenticate. +- Build PRM URLs: (1) `resource_metadata` if present, (2) path-based `/.well-known/oauth-protected-resource{path}`, (3) root `/.well-known/oauth-protected-resource`. Request each until PRM is obtained. +- Request `/.well-known/authorization_servers` (path relative to resource URL). Parse `protocols`; on failure or empty, use `prm.mcp_auth_protocols`. +- Combine protocol list with WWW-Authenticate `auth_protocols` if present, then select one via `AuthProtocolRegistry.select_protocol(available, default_protocol, preferences)`. + +**Relationship between authorization URL endpoints** + +There are three distinct URL trees involved: + +| Host | Endpoint | Owner | Purpose | +|------|----------|-------|---------| +| **Authorization Server (AS)** | `/.well-known/oauth-authorization-server` | AS | OAuth 2.0 metadata (RFC 8414): `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, etc. | +| **Authorization Server (AS)** | `/authorize`, `/token`, `/register`, `/introspect` | AS | OAuth flows and token introspection | +| **MCP Resource Server (RS)** | `/.well-known/oauth-protected-resource{path}` | RS | Protected Resource Metadata (RFC 9728): `resource`, `authorization_servers`, MCP extensions | +| **MCP Resource Server (RS)** | `/.well-known/authorization_servers` | RS | Unified protocol discovery (MCP extension): `protocols`, `default_protocol`, `protocol_preferences` | +| **MCP Resource Server (RS)** | `/{resource_path}` (e.g. `/mcp`) | RS | Protected MCP endpoint | + +**URL tree (example: AS on 9000, RS on 8002)** + +``` +Authorization Server (http://localhost:9000) +├── /.well-known/oauth-authorization-server ← OAuth AS metadata +├── /authorize +├── /token +├── /register +├── /introspect +└── /login, /login/callback ← (example-specific) + +MCP Resource Server (http://localhost:8002) +├── /.well-known/oauth-protected-resource/mcp ← PRM (path derived from resource_url) +├── /.well-known/authorization_servers ← Unified discovery (mounted at root) +└── /mcp ← Protected MCP endpoint +``` + +**Client discovery order** + +1. On 401, read `resource_metadata` from WWW-Authenticate (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). +2. If absent, try path-based: `{origin}/.well-known/oauth-protected-resource{resource_path}` (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). +3. If absent, try root: `{origin}/.well-known/oauth-protected-resource`. +4. PRM includes `authorization_servers` (AS URL) and `mcp_auth_protocols`; for OAuth, the client then fetches `{AS}/.well-known/oauth-authorization-server`. +5. For protocol list, client tries `{resource_url}/.well-known/authorization_servers` (e.g. `http://localhost:8002/mcp/.well-known/authorization_servers`); many RS mount discovery at origin `{origin}/.well-known/authorization_servers`. If that returns 404 or empty, client uses `prm.mcp_auth_protocols`. + +**References:** RFC 9728 (PRM), RFC 8414 (OAuth AS metadata), SDK `mcp.client.auth.utils` (`build_protected_resource_metadata_discovery_urls`, `discover_authorization_servers`). + +```mermaid +flowchart LR + subgraph 401 + A[401 Response] --> B[Extract WWW-Authenticate] + B --> C[resource_metadata?] + end + C --> D[PRM discovery] + D --> E[Try resource_metadata URL] + E --> F[Try path-based well-known] + F --> G[Try root well-known] + G --> H[PRM obtained] + H --> I[Unified discovery] + I --> J[GET /.well-known/authorization_servers] + J --> K{200 + protocols?} + K -->|Yes| L[Use protocols list] + K -->|No| M[Use PRM.mcp_auth_protocols] + L --> N[Select protocol] + M --> N +``` + +### 2.2 MCP client logic + +The client uses **MultiProtocolAuthProvider** (httpx.Auth) so that every HTTP request is prepared and 401/403 are handled in one place. + +**Main flow:** + +1. **Initialization** — On first use, `_initialize()` runs (e.g. register protocol classes). No network call yet. +2. **Before first request** — Read credentials from `TokenStorage` (`get_tokens` → `AuthCredentials | OAuthToken`). If present and valid (`protocol.validate_credentials`), call `protocol.prepare_request(request, credentials)` and, if DPoP is enabled and the protocol supports it, attach a DPoP proof. Then yield the request. +3. **On 401** — (See discovery above.) After obtaining protocols and selecting one: + - **If OAuth2:** Build an `OAuthClientProvider` from config and drive the shared **oauth_401_flow_generator** (all OAuth steps — AS discovery, registration, authorization, token exchange — are done by yielding requests; httpx sends them and injects responses back). No extra HTTP client; avoids lock deadlock. + - **If API Key / other:** Build `AuthContext`, call `protocol.authenticate(context)`, then store credentials and retry the original request with `prepare_request`. +4. **On 403** — Parse `error` / `scope` from WWW-Authenticate for logging; current implementation does not auto-retry. +5. **TokenStorage contract** — Storage may return `OAuthToken` or `AuthCredentials`. The provider converts OAuthToken → OAuthCredentials when reading and OAuthCredentials → OAuthToken when writing so that OAuth-only storage remains usable. + +**Protocol selection** — `AuthProtocolRegistry.select_protocol(available_protocols, default_protocol, protocol_preferences)` filters to registered protocols, then applies default and then preference order (lower number = higher priority). + +**References:** `mcp.client.auth.multi_protocol` (MultiProtocolAuthProvider, async_auth_flow), `mcp.client.auth._oauth_401_flow` (oauth_401_flow_generator), `mcp.client.auth.registry` (AuthProtocolRegistry), `mcp.client.auth.protocol` (AuthProtocol, DPoPEnabledProtocol). + +```mermaid +sequenceDiagram + participant HTTP as httpx + participant Provider as MultiProtocolAuthProvider + participant Registry as AuthProtocolRegistry + participant Protocol as AuthProtocol + participant Storage as TokenStorage + + HTTP->>Provider: async_auth_flow(request) + Provider->>Storage: get_tokens() + alt has valid credentials + Provider->>Protocol: prepare_request(request, creds) + Provider->>HTTP: yield request + HTTP->>Provider: response + end + alt response 401 + Provider->>HTTP: yield PRM request(s) + HTTP->>Provider: PRM response + Provider->>HTTP: yield discovery request + HTTP->>Provider: discovery response + Provider->>Registry: select_protocol(...) + alt OAuth2 + Provider->>HTTP: yield OAuth requests (gen) + HTTP->>Provider: OAuth responses + else API Key / other + Provider->>Protocol: authenticate(context) + Protocol->>Storage: set_tokens(creds) + Provider->>HTTP: yield retry request + end + end +``` + +### 2.3 MCP server logic + +The server exposes protected MCP endpoints and declares supported auth methods via PRM and (optionally) unified discovery; it then verifies credentials on each request. + +**Routes and metadata:** + +1. **Protected Resource Metadata (PRM)** — `create_protected_resource_routes(resource_url, authorization_servers, ..., auth_protocols, default_protocol, protocol_preferences)` registers `/.well-known/oauth-protected-resource{path}` (RFC 9728). The handler returns JSON including `resource`, `authorization_servers`, and MCP extensions `mcp_auth_protocols`, `mcp_default_auth_protocol`, `mcp_auth_protocol_preferences`. +2. **Unified discovery** — `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` registers `/.well-known/authorization_servers`. The handler returns `{ "protocols": [ AuthProtocolMetadata, ... ] }` plus optional default and preferences. +3. **401 responses** — Middleware (e.g. RequireAuthMiddleware) returns 401 with WWW-Authenticate including at least Bearer (and optionally `resource_metadata`, `auth_protocols`, `default_protocol`, `protocol_preferences`). + +**Configuration and URL tree — what each server must provide** + +**Authorization Server (AS) — required configuration /改造:** + +| Item | Description | +|------|-------------| +| `/.well-known/oauth-authorization-server` | **Must expose** (RFC 8414). Returns JSON with `authorization_endpoint`, `token_endpoint`, optionally `registration_endpoint`, `scopes_supported`. | +| `/authorize`, `/token` | **Must implement** — OAuth authorization code + PKCE flow. | +| `/register` | Optional — dynamic client registration. | +| `/introspect` | **Required if RS uses introspection** — RS calls this to validate Bearer/DPoP tokens. | +| **DPoP** | If DPoP is used: tokens must include `cnf` (e.g. `jkt`) so the RS can verify the DPoP proof. | + +**No change** to the AS is needed for *multi-protocol* itself. The AS only needs to support standard OAuth 2.0 and (optionally) DPoP-bound tokens. + +**MCP Resource Server (RS) — required configuration /改造:** + +| Item | Description | +|------|-------------| +| `resource_url` | Base URL of the protected resource (e.g. `http://localhost:8002/mcp`). Used to build PRM path: `/.well-known/oauth-protected-resource{path}`. | +| `authorization_servers` | List of AS URLs (e.g. `["http://localhost:9000"]`). PRM references these so clients know where to get tokens. | +| `auth_protocols` | List of `AuthProtocolMetadata` (protocol_id, protocol_version, metadata_url for OAuth, etc.). | +| `default_protocol` | Optional default protocol ID (e.g. `"oauth2"`). | +| `protocol_preferences` | Optional priority map (e.g. `{"oauth2": 1, "api_key": 2}`). | +| PRM route | Mount `create_protected_resource_routes(...)` so `/.well-known/oauth-protected-resource{path}` is served. Path is derived from `resource_url` (e.g. `/mcp` → `/.well-known/oauth-protected-resource/mcp`). | +| Unified discovery route | Mount `create_authorization_servers_discovery_routes(...)` so `/.well-known/authorization_servers` is served. Recommended at **origin root** (e.g. `http://localhost:8002/.well-known/authorization_servers`) so clients using path-relative discovery can fall back to PRM when 404. | +| WWW-Authenticate on 401 | Include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, `protocol_preferences` so MCP clients can discover without extra requests. | + +**Example config (simple-auth-multiprotocol):** + +```python +# Resource server settings +server_url = "http://localhost:8002/mcp" +auth_server_url = "http://localhost:9000" +auth_protocols = [ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0", + metadata_url=f"{auth_server_url}/.well-known/oauth-authorization-server", + scopes_supported=["user"]), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), + AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), +] +default_protocol = "oauth2" +protocol_preferences = {"oauth2": 1, "api_key": 2, "mutual_tls": 3} + +# PRM URL: http://localhost:8002/.well-known/oauth-protected-resource/mcp +# Discovery URL: http://localhost:8002/.well-known/authorization_servers +``` + +**Environment / config (simple-auth-multiprotocol example):** + +| Env / CLI | Description | +|-----------|-------------| +| `--port` | RS port (default 8002). | +| `--auth-server` | AS base URL (e.g. `http://localhost:9000`). Used for `authorization_servers` and OAuth `metadata_url`. | +| `--api-keys` | Comma-separated API keys for `APIKeyVerifier`. | +| `--dpop-enabled` | Enable DPoP proof verification. | +| `server_url` | Derived as `http://{host}:{port}/mcp`; used for PRM path and 401 `resource_metadata`. | + +**Verification:** + +1. **MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier` instances. For each request it calls `verifier.verify(request, dpop_verifier)` in order; the first non-None result wins. +2. **OAuthTokenVerifier** — Reads `Authorization: Bearer ` or `Authorization: DPoP `. Verifies the token (e.g. introspection); if DPoP-bound and `dpop_verifier` is set, validates the DPoP proof (method, URI, `ath`, jti replay). See RFC 9449. +3. **APIKeyVerifier** — Reads `X-API-Key` first, then falls back to `Authorization: Bearer ` and checks the value in `valid_keys`. Does not parse an `ApiKey` scheme. +4. **DPoP** — When enabled, the backend is constructed with a `DPoPProofVerifier` and passes it into each verifier. The verifier uses it only when the token is DPoP-bound (e.g. `Authorization: DPoP` and valid proof). + +**References:** `mcp.server.auth.routes` (create_protected_resource_routes, create_authorization_servers_discovery_routes), `mcp.server.auth.verifiers` (MultiProtocolAuthBackend, OAuthTokenVerifier, APIKeyVerifier), `mcp.server.auth.dpop` (DPoPProofVerifier). + +```mermaid +flowchart TB + subgraph Request + R[Incoming request] + end + R --> Backend[MultiProtocolAuthBackend.verify] + Backend --> V1[OAuthTokenVerifier.verify] + V1 --> DPoP{DPoP header?} + DPoP -->|Yes| DPoPVerify[DPoPProofVerifier.verify] + DPoP -->|No| TokenCheck[Token valid?] + DPoPVerify --> TokenCheck + TokenCheck -->|OK| OAuthOK[Return AccessToken] + TokenCheck -->|Fail| V2[APIKeyVerifier.verify] + V2 --> ApiKey{X-API-Key or Bearer in valid_keys?} + ApiKey -->|Yes| ApiKeyOK[Return AccessToken] + ApiKey -->|No| V3[Next verifier / None] + V3 --> None[401 Unauthorized] +``` + +--- + +## 3. How to use and integrate + +### 3.1 Authorization Server (AS) responsibilities + +When the resource server uses **OAuth 2.0** as one of the protocols: + +1. **Expose OAuth 2.0 metadata** — RFC 8414: `/.well-known/oauth-authorization-server` (or `/.well-known/openid-configuration` if applicable). Must include `authorization_endpoint`, `token_endpoint`, and optionally `registration_endpoint`, `scopes_supported`. +2. **Support authorization code + PKCE** — Authorization endpoint, token endpoint, and (optional) dynamic client registration. MCP clients use authorization code with PKCE and optionally DPoP-bound tokens. +3. **Token introspection (if used)** — Resource server may call the AS introspection endpoint to validate Bearer/DPoP tokens. DPoP-bound tokens require the AS to include `cnf` (e.g. `jkt`) in the token or introspection response so the RS can verify the DPoP proof (RFC 9449). + +No change is required to the AS for *multi-protocol* itself; the AS only needs to support the OAuth flows and (if DPoP is used) token binding. + +### 3.2 MCP client responsibilities + +1. **Choose auth model** — Use a single protocol (e.g. only OAuth via `OAuthClientProvider`) or multi-protocol via `MultiProtocolAuthProvider`. +2. **Register protocols** — Call `AuthProtocolRegistry.register(protocol_id, ProtocolClass)` for each supported protocol (e.g. `oauth2`, `api_key`) before creating the provider. +3. **Storage** — Provide a `TokenStorage` that implements `get_tokens()` → `AuthCredentials | OAuthToken | None` and `set_tokens(AuthCredentials | OAuthToken)`. For OAuth-only storage, the provider converts to/from OAuthToken internally; or use an adapter. +4. **Provider configuration** — Construct `MultiProtocolAuthProvider` with storage, optional `dpop_enabled`, optional `dpop_storage`, and (for 401 flows) an `http_client` that will be used to send yielded requests. Attach the provider as `httpx.Client(auth=provider)`. +5. **Environment / config** — For the example client: `MCP_SERVER_URL`, `MCP_API_KEY` (API Key), `MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1` (OAuth + DPoP). Protocol selection is driven by server discovery and registry. + +### 3.3 MCP server (resource server) responsibilities + +1. **Define protocols** — Build a list of `AuthProtocolMetadata` (protocol_id, protocol_version, metadata_url for OAuth, etc.) and optionally `default_protocol` and `protocol_preferences`. +2. **Mount PRM** — Call `create_protected_resource_routes(resource_url, authorization_servers, ..., auth_protocols=auth_protocols, default_protocol=..., protocol_preferences=...)` and mount the returned routes so that `/.well-known/oauth-protected-resource{path}` serves PRM JSON (RFC 9728 + MCP extensions). +3. **Mount unified discovery (optional)** — Call `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` and mount so that `/.well-known/authorization_servers` returns the protocol list. +4. **Build backend** — Instantiate `OAuthTokenVerifier`, `APIKeyVerifier`, and (if needed) other verifiers; pass them into `MultiProtocolAuthBackend`. If DPoP is used, create a `DPoPProofVerifier` and pass it into `backend.verify(request, dpop_verifier=...)`. +5. **401/403 responses** — Use middleware that returns 401 with WWW-Authenticate (Bearer at minimum; add `resource_metadata`, `auth_protocols`, `default_protocol`, `protocol_preferences` for MCP clients). Optionally return 403 with `error` and `scope` when appropriate. + +### 3.4 API reference (AuthProtocol, CredentialVerifier) + +#### AuthProtocol (`mcp.client.auth.protocol`) + +Client-side protocol interface. All auth protocols (OAuth2, API Key, etc.) must implement it. + +| Member | Type | Description | +|--------|------|-------------| +| `protocol_id` | `str` | Protocol identifier (e.g. `"oauth2"`, `"api_key"`). | +| `protocol_version` | `str` | Protocol version (e.g. `"2.0"`, `"1.0"`). | +| `authenticate(context)` | `async def` | Perform auth; return `AuthCredentials`. | +| `prepare_request(request, credentials)` | `def` | Add auth headers (e.g. `X-API-Key`, `Authorization: Bearer ...`). | +| `validate_credentials(credentials)` | `def` | Return `True` if credentials are still valid. | +| `discover_metadata(metadata_url, prm, http_client)` | `async def` | Optional; return protocol metadata from server. | + +**AuthContext** — Input to `authenticate`; includes `server_url`, `storage`, `protocol_id`, `protocol_metadata`, `current_credentials`, `dpop_storage`, `dpop_enabled`, `http_client`, `resource_metadata_url`, `protected_resource_metadata`, `scope_from_www_auth`. + +**DPoPEnabledProtocol** — Extends `AuthProtocol`; adds `supports_dpop()`, `get_dpop_proof_generator()`, `initialize_dpop()` for DPoP-bound tokens. + +#### CredentialVerifier (`mcp.server.auth.verifiers`) + +Server-side verifier interface. Each verifier checks one auth scheme. + +| Member | Type | Description | +|--------|------|-------------| +| `verify(request, dpop_verifier)` | `async def` | Inspect request; return `AccessToken` on success, `None` on failure. | + +**Implementations:** + +- **OAuthTokenVerifier** — Reads `Authorization: Bearer ` or `Authorization: DPoP `. Verifies token (e.g. introspection); if DPoP-bound and `dpop_verifier` is set, validates DPoP proof. +- **APIKeyVerifier** — Reads `X-API-Key` first, then `Authorization: Bearer ` if value is in `valid_keys`. Constructor: `APIKeyVerifier(valid_keys: set[str], scopes: list[str] | None = None)`. + +**MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier`; calls them in order; first non-None result wins. + +#### TokenStorage (multi-protocol contract) + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get_tokens()` | `async def` → `AuthCredentials \| OAuthToken \| None` | Return stored credentials. | +| `set_tokens(tokens)` | `async def` | Store `AuthCredentials` or `OAuthToken`. | + +For OAuth-only storage, the provider converts between `OAuthToken` and `OAuthCredentials` internally; no adapter required. + +### 3.5 Migration from OAuth-only: step-by-step guide + +If you currently use `OAuthClientProvider` / `simple-auth-client` and want to add multi-protocol (e.g. API Key or OAuth + DPoP): + +#### Step 1: Keep OAuth-only path (no change) + +- `OAuthClientProvider`, `simple-auth`, `simple-auth-client` remain as-is. +- No code changes if you only use OAuth. + +#### Step 2: Client — switch to MultiProtocolAuthProvider + +**Before (OAuth only):** +```python +from mcp.client.auth.oauth2 import OAuthClientProvider +provider = OAuthClientProvider(...) +client = httpx.AsyncClient(auth=provider) +``` + +**After (multi-protocol):** +```python +from mcp.client.auth.multi_protocol import MultiProtocolAuthProvider, TokenStorage +from mcp.client.auth.registry import AuthProtocolRegistry +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol + +# Register protocols before creating the provider +AuthProtocolRegistry.register("oauth2", OAuth2Protocol) +# If using API Key: AuthProtocolRegistry.register("api_key", ApiKeyProtocol) + +provider = MultiProtocolAuthProvider( + storage=your_storage, # must support AuthCredentials | OAuthToken + dpop_enabled=False, # set True for DPoP +) +client = httpx.AsyncClient(auth=provider) +# Pass http_client to provider if needed for 401 flows: +provider._http_client = client +``` + +#### Step 3: Storage — support both OAuthToken and AuthCredentials + +**If your storage only handles OAuthToken:** + +- No change required; the provider converts internally. +- Or use `OAuthTokenStorageAdapter` to wrap an OAuth-only storage. + +**If you add API Key:** +```python +async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + return self._creds # may be OAuthToken or APIKeyCredentials + +async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + self._creds = tokens +``` + +#### Step 4: Server — add MultiProtocolAuthBackend and PRM extensions + +**Before (OAuth only):** +```python +# Single OAuth verifier +token_verifier = TokenVerifier(...) +oauth_verifier = OAuthTokenVerifier(token_verifier) +# BearerAuthBackend or equivalent +``` + +**After (multi-protocol):** +```python +from mcp.server.auth.verifiers import ( + MultiProtocolAuthBackend, + OAuthTokenVerifier, + APIKeyVerifier, +) +from mcp.server.auth.routes import ( + create_protected_resource_routes, + create_authorization_servers_discovery_routes, +) +from mcp.shared.auth import AuthProtocolMetadata + +# Build protocol list +auth_protocols = [ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0", metadata_url=as_url, ...), + AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), +] + +# Verifiers +oauth_verifier = OAuthTokenVerifier(token_verifier) +api_key_verifier = APIKeyVerifier(valid_keys={"demo-api-key-12345"}) +backend = MultiProtocolAuthBackend([oauth_verifier, api_key_verifier]) + +# PRM with MCP extensions +prm_routes = create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[as_url], + auth_protocols=auth_protocols, + default_protocol="oauth2", +) +# Optional: unified discovery +discovery_routes = create_authorization_servers_discovery_routes( + protocols=auth_protocols, + default_protocol="oauth2", +) +# Mount prm_routes and discovery_routes +``` + +#### Step 5: 401 responses — add MCP extension parameters + +Ensure 401 WWW-Authenticate includes (when using multi-protocol): + +- `resource_metadata` — URL of PRM (e.g. `/.well-known/oauth-protected-resource/mcp`) +- `auth_protocols` — Space-separated protocol IDs (e.g. `oauth2 api_key`) +- `default_protocol` — Optional default (e.g. `oauth2`) +- `protocol_preferences` — Optional priorities (e.g. `oauth2:1,api_key:2`) + +See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these are set. + +--- + +## 4. Integration test examples + +### 4.1 Phase 2: Multi-protocol (API Key, OAuth, mTLS placeholder) + +**Script:** `./scripts/run_phase2_multiprotocol_integration_test.sh` (from repo root). + +**Behavior:** + +- Starts the multi-protocol resource server (`simple-auth-multiprotocol-rs`) on port 8002 with `--api-keys=demo-api-key-12345`. For OAuth, also starts the AS (`simple-auth-as`) on port 9000. +- Waits for PRM: `GET http://localhost:8002/.well-known/oauth-protected-resource/mcp`. +- Runs the client according to `MCP_PHASE2_PROTOCOL`: + - **api_key** (default): `simple-auth-multiprotocol-client` with `MCP_SERVER_URL=http://localhost:8002/mcp` and `MCP_API_KEY=demo-api-key-12345`. No AS required. + - **oauth**: `simple-auth-client` against the same RS; user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. + - **mutual_tls**: same multiprotocol client without API key; mTLS is a placeholder (no real client cert). + +**What it demonstrates:** PRM and optional unified discovery, protocol selection (API Key vs OAuth), and that API Key works without an AS. + +### 4.2 Phase 4: DPoP integration + +**Script:** `./scripts/run_phase4_dpop_integration_test.sh` (from repo root). + +**Behavior:** + +- Starts AS on 9000 and RS on 8002 with `--dpop-enabled` and an API key. +- Runs **automated** curl tests: + - **B2:** API Key request → 200 (DPoP does not affect API Key). + - **A2:** Bearer token without DPoP proof → 401 (RS requires DPoP when token is DPoP-bound). + - Negative: fake token, wrong htm/htu, DPoP without Authorization → 401. +- Optionally runs **manual** OAuth+DPoP client test: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1` with the multiprotocol client; user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". + +**Env:** `MCP_SKIP_OAUTH=1` skips the manual client step and only runs the automated curl tests. + +**What it demonstrates:** DPoP proof verification on the server, rejection of Bearer without proof when DPoP is required, and successful OAuth+DPoP flow with the example client. + +### 4.3 Test matrix (reference) + +| Case | Auth type | Expected result | +|------|------------------|-----------------| +| B2 | API Key | 200 (DPoP irrelevant) | +| A2 | Bearer, no DPoP | 401 when RS expects DPoP | +| A1 | OAuth + DPoP | 200 after browser OAuth | +| — | No auth | 401 | +| — | DPoP proof, fake token / wrong htm or htu | 401 | + +--- + +## 5. Current limitations and future evolution + +### 5.1 Limitations + +1. **Mutual TLS** — Only a placeholder: the protocol is advertised and selectable, but the example server does not perform client certificate validation. A full mTLS implementation would require TLS client cert handling and a verifier that checks the certificate. +2. **Unified discovery URL** — Discovery uses `/.well-known/authorization_servers` path-relative to the *resource* URL (e.g. `http://host:8002/mcp` → `http://host:8002/mcp/.well-known/authorization_servers`). Some servers may instead serve this at the origin (e.g. `http://host:8002/.well-known/authorization_servers`); fallback to PRM’s `mcp_auth_protocols` covers that. +3. **403 handling** — The client parses 403 WWW-Authenticate for logging but does not automatically retry with a new scope or token; that could be extended for specific error/scope values. +4. **DPoP nonce** — Server-side DPoP nonce (RFC 9449) is not yet used in the example; only jti replay protection is in place. Adding nonce would improve robustness against pre-replay. +5. **TokenStorage** — The dual contract (OAuthToken vs AuthCredentials) and in-memory conversion are documented; a formal adapter type or storage interface versioning could simplify integration for new backends. + +### 5.2 Possible evolution + +- **Full mTLS example** — Implement client certificate validation and a verifier that maps the client cert to an identity/scope. +- **Discovery flexibility** — Support configurable discovery URL templates or multiple well-known paths so both path-relative and origin-relative discovery work without relying only on PRM fallback. +- **403 retry policy** — Define retry rules for 403 (e.g. `insufficient_scope`) and integrate with OAuth scope refresh or re-authorization. +- **DPoP nonce** — Implement server-initiated nonce and client nonce handling per RFC 9449. + +--- + +**Related documentation:** [Authorization](authorization.md) (overview), [API Reference](api.md). +**Examples:** [simple-auth-multiprotocol](../examples/servers/simple-auth-multiprotocol/), [simple-auth-multiprotocol-client](../examples/clients/simple-auth-multiprotocol-client/), [examples/README.md](../examples/README.md). diff --git a/docs/authorization.md b/docs/authorization.md index 4b6208bdf..4a0bfae0c 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -1,5 +1,16 @@ # Authorization -!!! warning "Under Construction" +The MCP Python SDK supports **multi-protocol authorization**: OAuth 2.0, API Key, DPoP (Demonstrating Proof-of-Possession), and a Mutual TLS placeholder. Servers declare supported protocols via PRM (Protected Resource Metadata) and WWW-Authenticate; clients discover and select a protocol automatically. - This page is currently being written. Check back soon for complete documentation. +## Overview + +- **OAuth 2.0**: Authorization code flow with PKCE; 401 → discovery → OAuth → token → MCP. Fully supported with `OAuthClientProvider` / `OAuth2Protocol`. +- **API Key**: Send `X-API-Key` (or `Authorization: Bearer ` when configured). No AS required. Use `MCP_API_KEY` on the client and `--api-keys` on the server. +- **DPoP** (RFC 9449): Binds the access token to a client-held key. Use with OAuth: client sets `MCP_USE_OAUTH=1` and `MCP_DPOP_ENABLED=1`; server starts with `--dpop-enabled`. +- **Mutual TLS**: Placeholder in the examples (no client certificate validation). + +Examples: [simple-auth-multiprotocol](../examples/servers/simple-auth-multiprotocol/) (server), [simple-auth-multiprotocol-client](../examples/clients/simple-auth-multiprotocol-client/) (client). See [examples/README.md](../examples/README.md) for API Key, DPoP, and mTLS running instructions. + +--- + +For protocol implementation, migration from OAuth-only to multi-protocol, DPoP usage, and API reference, see **[Authorization: Multi-Protocol Extension](authorization-multiprotocol.md)**. From b5e31727cc162c22e03941929043fba4ffd5231d Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sun, 1 Feb 2026 23:32:45 +0800 Subject: [PATCH 41/64] Fix multi authentication schema design doc --- .../auth/multi-protocol-refactoring-plan.md | 100 +++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md index cfc42fa2c..f3a7604dc 100644 --- a/src/mcp/client/auth/multi-protocol-refactoring-plan.md +++ b/src/mcp/client/auth/multi-protocol-refactoring-plan.md @@ -2,6 +2,8 @@ > 基于章节12.5(与OAuth的区别)和章节13(现有代码改造点清单),结合DPoP抽象设计,制定的完整改造计划 +**相关文档**:`docs/authorization-multiprotocol.md`(多协议设计与用法)、`docs/dpop-nonce-implementation-plan.md`(DPoP nonce 实现方案)、`mcp/client/auth/multi-protocol-design.md`(顶层设计) + ## 一、改造目标 ### 1.1 核心目标 @@ -277,7 +279,7 @@ DPoP作为独立的通用组件,协议可以选择性使用: **优先级**: 🟡 中 **改造内容**: -1. **新增统一能力发现端点支持** +1. **新增统一能力发现端点支持**(发现顺序取舍见 **十一、11.4**) ```python async def discover_authorization_servers( resource_url: str, @@ -427,7 +429,7 @@ DPoP作为独立的通用组件,协议可以选择性使用: # 4. 发送请求 response = yield request - # 5. 处理401/403响应 + # 5. 处理401/403响应(OAuth 分支通过 oauth_401_flow_generator 驱动,取舍见十一、11.1) if response.status_code == 401: await self._handle_401_response(response, request) elif response.status_code == 403: @@ -437,6 +439,7 @@ DPoP作为独立的通用组件,协议可以选择性使用: 2. **OAuthClientProvider 保持为 OAuth 逻辑唯一实现(最大程度复用)** - **不**将 OAuth 逻辑迁出到 OAuth2Protocol;新增 `run_authentication(http_client, ...)` 供多协议路径调用 - 保持现有 API 不变(向后兼容);OAuth2Protocol 为薄适配层,内部委托 `OAuthClientProvider.run_authentication` + - 取舍原因见 **十一、设计取舍与方案说明 11.1** 3. **协议上下文扩展** ```python @@ -529,6 +532,8 @@ DPoP作为独立的通用组件,协议可以选择性使用: #### 3.2.7 API Key 认证方案约定(方案 A) +**取舍**:采用 X-API-Key + 可选 Bearer,不解析非标准 `ApiKey` scheme。取舍原因见 **十一、11.2**。 + **约定**(与前述 3.2.5 协议特定的实现示例一致): - **标准兼容**:不解析非标准 `Authorization: ApiKey `(`ApiKey` 非 IANA 注册 scheme);API Key 使用标准 `Bearer` 或专用 header。 - **服务端**:优先从 `X-API-Key` header 读取;可选从 `Authorization: Bearer ` 读取并在 `valid_keys` 中查找;由 `MultiProtocolAuthBackend` 的验证器顺序区分(OAuthTokenVerifier 先尝试 Bearer → TokenVerifier,APIKeyVerifier 再尝试 X-API-Key / Bearer-in-valid_keys)。 @@ -1043,6 +1048,8 @@ DPoP作为独立的通用组件,协议可以选择性使用: - 依赖:协议抽象接口 - 注意:这是可选功能,可以跳过 +**DPoP Nonce**:阶段4 完成后可按 `docs/dpop-nonce-implementation-plan.md` 实现 RS/Client/AS 侧 nonce 支持;与当前 DPoP 基础实现正交。 + ### 4.4 依赖关系图 ```mermaid @@ -1301,6 +1308,8 @@ graph TD 5. **渐进式迁移**:可以逐步启用新功能 6. **最小化接口**:基础接口只包含必需方法,可选功能通过扩展接口实现 +**设计取舍**:OAuth 薄适配层、Generator 驱动 401 流程、API Key 方案 A、协议发现顺序、DPoP nonce 风险化解等详见 **十一、设计取舍与方案说明**。 + **预计时间**: - 核心功能:7周(阶段1-3) - 完整功能(含DPoP):9周(阶段1-5,阶段4可选) @@ -1310,3 +1319,90 @@ graph TD - 熟悉Python异步编程 - 熟悉HTTP协议和RESTful API设计 - 熟悉测试驱动开发 + +--- + +## 十一、设计取舍与方案说明 + +本节汇总历史讨论中的关键设计决策,说明多方案并存时的取舍原因,便于后续实现与评审时对齐。 + +### 11.1 OAuth 逻辑复用与 401 流程驱动 + +多协议下的 OAuth 集成涉及两个相关联的取舍:**逻辑归属**(薄适配层 vs 逻辑迁移)与 **401 流程驱动方式**(Generator vs 新建 HTTP 客户端)。 + +**逻辑归属 — 可选方案**: +- **方案 A**:将 OAuth 逻辑迁出到 `OAuth2Protocol`,`OAuthClientProvider` 仅作为遗留入口 +- **方案 B**:`OAuth2Protocol` 为薄适配层,内部委托 `OAuthClientProvider.run_authentication`,OAuth 逻辑保持在 `oauth2.py` + +**401 流程驱动 — 可选方案**: +- **方案 A**:在 401 处理分支内新建 `httpx.AsyncClient`,独立发送 OAuth 相关请求 +- **方案 B**:使用共享的 `oauth_401_flow_generator`,由 `MultiProtocolAuthProvider` 驱动,所有 OAuth 步骤通过 `yield` 请求交由同一 `http_client` 发送 + +**取舍**:二者均采用 **方案 B**(薄适配层 + Generator 驱动)。 + +**原因**: +1. 最大程度复用现有 OAuth 实现,降低迁移风险与回归面;`OAuthClientProvider` 仍为 OAuth 逻辑唯一实现,避免双轨维护 +2. 薄适配层通过 `run_authentication(http_client, ...)` 调用,自然要求由调用方传入 `http_client`;Generator 模式使 `MultiProtocolAuthProvider` 作为驱动方,用同一 `http_client` 发送所有 OAuth 请求,二者设计上互锁 +3. 避免在 httpx 认证流程中创建新客户端导致的锁死锁风险;请求统一由 `httpx.Client(auth=provider)` 使用的同一 `http_client` 发送,行为可预测 +4. OAuth 流程(AS 发现、注册、授权、Token 交换)全部由 generator 产出请求,驱动方负责发送并回传响应;现有 `OAuthClientProvider` 用户无需改动 + +### 11.2 API Key 认证方案:标准 scheme vs 自定义 scheme + +**可选方案**: +- **方案 A**:使用 `X-API-Key` + 可选 `Authorization: Bearer `,不解析非标准 `Authorization: ApiKey ` +- **方案 B**:使用自定义 `Authorization: ApiKey ` scheme + +**取舍**:采用 **方案 A**。 + +**原因**: +1. `ApiKey` 非 IANA 注册的 HTTP Authentication scheme,方案 B 不符合 HTTP 规范 +2. RFC 6750 规定 Bearer token 为 opaque string,使用 `Bearer` 承载 API Key 语义合理 +3. 不在 token 内加前缀(如 `apikey:xxx`);区分由验证器顺序与 `valid_keys` 完成,符合 Bearer 不解析 token 内容的约定 + +### 11.3 Mutual TLS 与 IANA "Mutual" scheme + +**说明**:IANA 注册的 "Mutual" scheme(RFC 8120)表示基于密码的双向认证,与基于客户端证书的 Mutual TLS(mTLS)不同。 + +**取舍**:mTLS 在 TLS 握手层处理,不解析 HTTP `Authorization` 头;`Mutual TLS` 验证器从 TLS 连接/握手上下文读取客户端证书并校验。 + +### 11.4 协议发现顺序:统一端点 vs PRM 优先 + +**取舍**:客户端优先请求 `/.well-known/authorization_servers`(统一发现);若 404 或空,回退到 PRM 的 `mcp_auth_protocols`。 + +**原因**:统一端点便于多协议声明与扩展;PRM 回退保证仅支持 RFC 9728 的 RS 仍可被多协议客户端发现。 + +### 11.5 授权端点归属:AS 与 RS 的 URL 树 + +| 端点 | 归属 | 用途 | +|------|------|------| +| `/.well-known/oauth-authorization-server` | AS | OAuth 元数据(RFC 8414) | +| `/authorize`, `/token`, `/register`, `/introspect` | AS | OAuth 流程 | +| `/.well-known/oauth-protected-resource{path}` | RS | PRM(RFC 9728) | +| `/.well-known/authorization_servers` | RS | 统一协议发现(MCP 扩展) | + +**说明**:AS 与 RS 可能部署在不同主机(如 AS 9000、RS 8002);客户端先向 RS 获取 PRM/协议列表,再根据 `metadata_url` 向 AS 获取 OAuth 元数据。 + +### 11.6 TokenStorage 双契约:OAuthToken vs AuthCredentials + +**取舍**:`TokenStorage` 支持 `get_tokens() → AuthCredentials | OAuthToken | None` 与 `set_tokens(AuthCredentials | OAuthToken)`;`MultiProtocolAuthProvider` 内部负责 OAuthToken 与 OAuthCredentials 的转换。 + +**原因**:现有 OAuth 存储只处理 `OAuthToken`;多协议存储需处理 `APIKeyCredentials` 等。双契约 + 内部转换使 OAuth 存储无需改造即可工作。 + +### 11.7 DPoP Nonce 实现:风险与方案 + +DPoP nonce 详细方案见 `docs/dpop-nonce-implementation-plan.md`。关键取舍如下: + +| 风险 | 解决方案 | +|------|----------| +| **Token 请求 DPoP 缺失** | 单独 TODO 6a 实现 Token 请求 DPoP 与 400 `use_dpop_nonce` 重试,作为 AS nonce 前置依赖 | +| **AS 改造范围过大** | 拆分为 TODO 6b(SDK TokenHandler DPoP+nonce)与 TODO 6c(simple-auth 示例 DPoP-bound token),各 ≤300 行 | + +**分阶段**:先 RS + Client nonce(TODO 1–5),后 AS nonce(TODO 6a–6c),降低单次改动量。 + +### 11.8 测试 skipped 说明 + +全量回归中约有 95 个 skipped: +- **约 90+** 来自 `tests/experimental/tasks/test_spec_compliance.py`:占位测试,内部 `pytest.skip("TODO")`,与多协议改造无关 +- **其余**:平台条件(如 Windows 专用、无 `tee` 命令)、显式跳过(如 SSE timeout 相关 bug 测试) + +改造过程中不修改上述 skip 逻辑。 From 3159ef0da15428f8392f2e5bf56f0ff4cd609a60 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 5 Feb 2026 00:52:28 +0800 Subject: [PATCH 42/64] docs(auth): protocol discovery order and auth discovery logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authorization-multiprotocol.md: discovery order (PRM → path-relative → root → OAuth fallback), client flow, [Auth discovery] DEBUG logs - auth-analysis.md: multi-protocol discovery order and logging in §4.2, §12.1.3, §13.2.2 - multi-protocol-refactoring-plan.md: §11.4 PRM-first order, §11.5 note, §3.2.2 pseudo-code; auth discovery logging --- docs/authorization-multiprotocol.md | 34 ++++++++++----- src/mcp/client/auth/auth-analysis.md | 28 +++++++++---- .../auth/multi-protocol-refactoring-plan.md | 41 +++++++++---------- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md index 6430b0029..3889bbfd6 100644 --- a/docs/authorization-multiprotocol.md +++ b/docs/authorization-multiprotocol.md @@ -42,13 +42,20 @@ Discovery answers: *Which auth protocols does this resource support, and where i 1. **WWW-Authenticate on 401** — The resource server may include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, `protocol_preferences` (MCP extensions). See RFC 6750 (Bearer) and RFC 9728 (resource metadata). 2. **Protected Resource Metadata (PRM)** — RFC 9728 defines `/.well-known/oauth-protected-resource` (optionally with path). PRM JSON includes `authorization_servers`, and the SDK extends it with `mcp_auth_protocols`, `mcp_default_auth_protocol`, `mcp_auth_protocol_preferences`. -3. **Unified discovery endpoint** — `/.well-known/authorization_servers` returns a list of protocol metadata (MCP-style). Clients try this first; if it fails or returns no protocols, they fall back to PRM’s `mcp_auth_protocols`. +3. **Unified discovery endpoint** — `/.well-known/authorization_servers` returns a list of protocol metadata (MCP-style). The client tries path-relative first, then root (see protocol discovery order below). + +**Protocol discovery order (priority):** + +1. **Priority 1: PRM `mcp_auth_protocols`** — If PRM was obtained and contains `mcp_auth_protocols`, use that list. +2. **Priority 2: Path-relative unified discovery** — `{origin}/.well-known/authorization_servers{resource_path}` (e.g. `http://localhost:8002/.well-known/authorization_servers/mcp`). +3. **Priority 3: Root unified discovery** — `{origin}/.well-known/authorization_servers`. +4. **Priority 4: OAuth fallback** — If unified discovery failed and PRM has `authorization_servers`, attempt OAuth protocol discovery. **Client-side logic (high level):** - On 401, extract `resource_metadata` from WWW-Authenticate. - Build PRM URLs: (1) `resource_metadata` if present, (2) path-based `/.well-known/oauth-protected-resource{path}`, (3) root `/.well-known/oauth-protected-resource`. Request each until PRM is obtained. -- Request `/.well-known/authorization_servers` (path relative to resource URL). Parse `protocols`; on failure or empty, use `prm.mcp_auth_protocols`. +- For protocol list: if PRM has `mcp_auth_protocols`, use it (priority 1). Else try path-relative `/.well-known/authorization_servers{path}`, then root `/.well-known/authorization_servers`. If both fail and PRM has `authorization_servers`, use OAuth fallback. - Combine protocol list with WWW-Authenticate `auth_protocols` if present, then select one via `AuthProtocolRegistry.select_protocol(available, default_protocol, preferences)`. **Relationship between authorization URL endpoints** @@ -66,7 +73,7 @@ There are three distinct URL trees involved: **URL tree (example: AS on 9000, RS on 8002)** ``` -Authorization Server (http://localhost:9000) +OAuth Authorization Server (http://localhost:9000) ├── /.well-known/oauth-authorization-server ← OAuth AS metadata ├── /authorize ├── /token @@ -86,7 +93,9 @@ MCP Resource Server (http://localhost:8002) 2. If absent, try path-based: `{origin}/.well-known/oauth-protected-resource{resource_path}` (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). 3. If absent, try root: `{origin}/.well-known/oauth-protected-resource`. 4. PRM includes `authorization_servers` (AS URL) and `mcp_auth_protocols`; for OAuth, the client then fetches `{AS}/.well-known/oauth-authorization-server`. -5. For protocol list, client tries `{resource_url}/.well-known/authorization_servers` (e.g. `http://localhost:8002/mcp/.well-known/authorization_servers`); many RS mount discovery at origin `{origin}/.well-known/authorization_servers`. If that returns 404 or empty, client uses `prm.mcp_auth_protocols`. +5. For protocol list (in order): (1) If PRM has `mcp_auth_protocols`, use it. (2) Else try path-relative `{origin}/.well-known/authorization_servers{resource_path}` (e.g. `http://localhost:8002/.well-known/authorization_servers/mcp`). (3) Else try root `{origin}/.well-known/authorization_servers`. (4) If all fail and PRM has `authorization_servers`, use OAuth fallback. + +**Auth discovery logging:** When discovery runs, the SDK emits debug-level logs (English, `[Auth discovery]` prefix) for each PRM and unified-discovery request: URL, status code, and (on 200) pretty-printed response body. Set `LOG_LEVEL=DEBUG` on the client to see them. Implemented in `mcp.client.auth.utils` (`format_json_for_logging`, `handle_protected_resource_response`, `discover_authorization_servers`) and `mcp.client.auth.multi_protocol` (`_parse_protocols_from_discovery_response`, `async_auth_flow`). **References:** RFC 9728 (PRM), RFC 8414 (OAuth AS metadata), SDK `mcp.client.auth.utils` (`build_protected_resource_metadata_discovery_urls`, `discover_authorization_servers`). @@ -101,13 +110,18 @@ flowchart LR E --> F[Try path-based well-known] F --> G[Try root well-known] G --> H[PRM obtained] - H --> I[Unified discovery] - I --> J[GET /.well-known/authorization_servers] + H --> I{PRM.mcp_auth_protocols?} + I -->|Yes| N[Select protocol] + I -->|No| J[Path-relative unified discovery] J --> K{200 + protocols?} - K -->|Yes| L[Use protocols list] - K -->|No| M[Use PRM.mcp_auth_protocols] - L --> N[Select protocol] - M --> N + K -->|Yes| N + K -->|No| L[Root unified discovery] + L --> M{200 + protocols?} + M -->|Yes| N + M -->|No| O{PRM.authorization_servers?} + O -->|Yes| P[OAuth fallback] + O -->|No| Q[Fail] + P --> N ``` ### 2.2 MCP client logic diff --git a/src/mcp/client/auth/auth-analysis.md b/src/mcp/client/auth/auth-analysis.md index 7c44cf24a..cebcad434 100644 --- a/src/mcp/client/auth/auth-analysis.md +++ b/src/mcp/client/auth/auth-analysis.md @@ -315,6 +315,10 @@ flowchart TD 4. `/.well-known/oauth-authorization-server` 5. `/.well-known/openid-configuration` +4. **多协议客户端的协议发现顺序**(使用 `MultiProtocolAuthProvider` 时) + - 协议列表获取优先级:(1)PRM 的 `mcp_auth_protocols`(若已取得 PRM);(2)路径相对统一发现 `/.well-known/authorization_servers{path}`;(3)根路径统一发现 `/.well-known/authorization_servers`;(4)若均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退。 + - **鉴权发现日志**:发现过程在 `mcp.client.auth` 中输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志;客户端设置 `LOG_LEVEL=DEBUG` 可查看。 + #### 阶段 2: Scope 选择 根据 MCP 规范的 Scope 选择策略,按以下优先级选择 scope: @@ -914,10 +918,15 @@ provider = PrivateKeyJWTOAuthProvider( - 支持非 Bearer 的认证方案 #### 12.1.3 协议发现机制 -- **统一的能力发现端点**(推荐) - - 实现 `/.well-known/authorization_servers` 端点 +- **协议发现顺序**(客户端) + - 优先级 1:PRM 的 `mcp_auth_protocols`(若已取得 PRM) + - 优先级 2:路径相对统一发现 `/.well-known/authorization_servers{path}` + - 优先级 3:根路径统一发现 `/.well-known/authorization_servers` + - 优先级 4:若上述均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退 +- **统一的能力发现端点** + - 实现 `/.well-known/authorization_servers`(及路径相对版本 `/.well-known/authorization_servers{path}`) - 返回服务器支持的所有授权协议列表和能力信息 - - 客户端首先访问此端点发现可用协议 +- **鉴权发现日志**:发现过程输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志;客户端设置 `LOG_LEVEL=DEBUG` 可查看。 - **协议特定的元数据发现** - 每个协议可以提供自己的元数据发现端点 @@ -1313,15 +1322,18 @@ class AuthProtocol(Protocol): **当前函数**: `build_protected_resource_metadata_discovery_urls()`, `build_oauth_authorization_server_metadata_discovery_urls()` **改造内容**: -1. **新增统一能力发现端点支持** +1. **新增统一能力发现端点支持**(发现顺序:PRM 优先,再路径相对/根路径统一发现,最后 OAuth 回退) ```python async def discover_authorization_servers( resource_url: str, - http_client: httpx.AsyncClient + http_client: httpx.AsyncClient, + prm: ProtectedResourceMetadata | None = None, + resource_path: str = "", ) -> list[AuthProtocolMetadata]: - """统一的授权服务器发现流程""" - # 1. 首先访问统一的能力发现端点 - # 2. 根据返回的列表,访问每个协议的元数据端点 + """协议发现:PRM.mcp_auth_protocols → 路径相对统一发现 → 根路径统一发现 → OAuth 回退""" + # 1. 若已有 PRM 且含 mcp_auth_protocols,直接使用 + # 2. 路径相对 /.well-known/authorization_servers{path},再根路径 + # 3. 若仍无协议列表且 PRM 含 authorization_servers,由调用方走 OAuth 回退 ``` 2. **新增协议特定的元数据发现** diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md index f3a7604dc..5a82fe063 100644 --- a/src/mcp/client/auth/multi-protocol-refactoring-plan.md +++ b/src/mcp/client/auth/multi-protocol-refactoring-plan.md @@ -283,24 +283,21 @@ DPoP作为独立的通用组件,协议可以选择性使用: ```python async def discover_authorization_servers( resource_url: str, - http_client: httpx.AsyncClient + http_client: httpx.AsyncClient, + prm: ProtectedResourceMetadata | None = None, + resource_path: str = "", ) -> list[AuthProtocolMetadata]: - """统一的授权服务器发现流程""" - # 1. 首先访问统一的能力发现端点 - discovery_url = f"{resource_url}/.well-known/authorization_servers" - try: - response = await http_client.get(discovery_url) - if response.status_code == 200: - data = response.json() - return [ - AuthProtocolMetadata(**proto_data) - for proto_data in data.get("protocols", []) - ] - except Exception: - pass - - # 2. 回退:从PRM中获取协议信息 - # 3. 回退:使用协议特定的well-known URI + """统一的授权服务器/协议发现流程(PRM 优先,再统一发现,最后 OAuth 回退)""" + # 1. 若已有 PRM 且含 mcp_auth_protocols,直接使用 + if prm and getattr(prm, "mcp_auth_protocols", None): + return _protocol_metadata_list_from_prm(prm) + # 2. 路径相对统一发现:/.well-known/authorization_servers{path} + urls = build_authorization_servers_discovery_urls(resource_url, resource_path) + for url in urls: + # 尝试请求,200 且含 protocols 则解析并返回 + ... + # 3. 若仍无协议列表且 PRM 含 authorization_servers,走 OAuth 回退(由调用方处理) + return [] ``` 2. **新增协议特定的元数据发现** @@ -1365,11 +1362,13 @@ graph TD **取舍**:mTLS 在 TLS 握手层处理,不解析 HTTP `Authorization` 头;`Mutual TLS` 验证器从 TLS 连接/握手上下文读取客户端证书并校验。 -### 11.4 协议发现顺序:统一端点 vs PRM 优先 +### 11.4 协议发现顺序:PRM 优先 vs 统一发现 + +**取舍**:客户端协议发现顺序为:(1)PRM 的 `mcp_auth_protocols`(若已取得 PRM);(2)路径相对统一发现 `/.well-known/authorization_servers{path}`;(3)根路径统一发现 `/.well-known/authorization_servers`;(4)若上述均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退。 -**取舍**:客户端优先请求 `/.well-known/authorization_servers`(统一发现);若 404 或空,回退到 PRM 的 `mcp_auth_protocols`。 +**原因**:PRM 为 RFC 9728 标准且常与 401 的 `resource_metadata` 一起使用,优先使用可减少往返;统一发现作为补充;OAuth 回退保证仅实现 RFC 9728 的 RS 仍可被多协议客户端使用。 -**原因**:统一端点便于多协议声明与扩展;PRM 回退保证仅支持 RFC 9728 的 RS 仍可被多协议客户端发现。 +**鉴权发现日志**:发现过程在 `mcp.client.auth` 中输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志(请求 URL、状态码及 200 时的可读响应体);客户端设置 `LOG_LEVEL=DEBUG` 可查看。 ### 11.5 授权端点归属:AS 与 RS 的 URL 树 @@ -1380,7 +1379,7 @@ graph TD | `/.well-known/oauth-protected-resource{path}` | RS | PRM(RFC 9728) | | `/.well-known/authorization_servers` | RS | 统一协议发现(MCP 扩展) | -**说明**:AS 与 RS 可能部署在不同主机(如 AS 9000、RS 8002);客户端先向 RS 获取 PRM/协议列表,再根据 `metadata_url` 向 AS 获取 OAuth 元数据。 +**说明**:AS 与 RS 可能部署在不同主机(如 AS 9000、RS 8002);客户端按 11.4 所述顺序向 RS 获取协议列表(PRM 优先,再统一发现),再根据 `metadata_url` 向 AS 获取 OAuth 元数据。 ### 11.6 TokenStorage 双契约:OAuthToken vs AuthCredentials From 9179e8fb8b628bde24cc85529a0313e70e0fbf72 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Wed, 4 Feb 2026 17:39:13 +0800 Subject: [PATCH 43/64] docs(examples): rename MCP_AUTH_PROTOCOL and update guides Update multiprotocol docs/READMEs to use MCP_AUTH_PROTOCOL (no PHASE wording) and align example instructions with the hardened scripts. --- docs/authorization-multiprotocol.md | 2 +- examples/README.md | 5 +++-- examples/clients/simple-auth-multiprotocol-client/README.md | 6 +++--- examples/servers/simple-auth-multiprotocol/README.md | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md index 3889bbfd6..41080505e 100644 --- a/docs/authorization-multiprotocol.md +++ b/docs/authorization-multiprotocol.md @@ -467,7 +467,7 @@ See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these a - Starts the multi-protocol resource server (`simple-auth-multiprotocol-rs`) on port 8002 with `--api-keys=demo-api-key-12345`. For OAuth, also starts the AS (`simple-auth-as`) on port 9000. - Waits for PRM: `GET http://localhost:8002/.well-known/oauth-protected-resource/mcp`. -- Runs the client according to `MCP_PHASE2_PROTOCOL`: +- Runs the client according to `MCP_AUTH_PROTOCOL`: - **api_key** (default): `simple-auth-multiprotocol-client` with `MCP_SERVER_URL=http://localhost:8002/mcp` and `MCP_API_KEY=demo-api-key-12345`. No AS required. - **oauth**: `simple-auth-client` against the same RS; user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. - **mutual_tls**: same multiprotocol client without API key; mTLS is a placeholder (no real client cert). diff --git a/examples/README.md b/examples/README.md index 2959595c2..4cb8dec82 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,7 +11,7 @@ for real-world servers. **API Key** - Use `MCP_API_KEY` on the client; start RS with `--api-keys=...` (no AS required). -- One-command test (from repo root): `MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +- One-command test (from repo root): `MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` **OAuth + DPoP** @@ -20,6 +20,7 @@ for real-world servers. **Mutual TLS (placeholder)** -- mTLS is a placeholder (no client cert validation). Script: `MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` +- mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` +- mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` **Client**: [simple-auth-multiprotocol-client](clients/simple-auth-multiprotocol-client/) — supports API Key (`MCP_API_KEY`), OAuth+DPoP (`MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1`), and mTLS placeholder. diff --git a/examples/clients/simple-auth-multiprotocol-client/README.md b/examples/clients/simple-auth-multiprotocol-client/README.md index 7c5020793..5e2d95f81 100644 --- a/examples/clients/simple-auth-multiprotocol-client/README.md +++ b/examples/clients/simple-auth-multiprotocol-client/README.md @@ -26,7 +26,7 @@ MCP_SERVER_URL=http://localhost:8002/mcp MCP_API_KEY=demo-api-key-12345 uv run m ``` **One-command test** from repo root: -`MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +`MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` starts the resource server and this client with API Key; at `mcp>` run `list`, `call get_time {}`, `quit`. ## Running with OAuth + DPoP @@ -51,10 +51,10 @@ Complete OAuth in the browser; then at `mcp>` run `list`, `call get_time {}`, `q Mutual TLS is a **placeholder** in this example: the client registers the `mutual_tls` protocol but does **not** perform client certificate authentication. Selecting mTLS will show a "not implemented" style message. -- **`MCP_PHASE2_PROTOCOL=mutual_tls`** (with the phase2 script) runs this client in mTLS mode; the client will start but mTLS auth is not implemented. +- **`MCP_AUTH_PROTOCOL=mutual_tls`** (with the phase2 script) runs this client in mTLS mode; the client will start but mTLS auth is not implemented. **One-command test** from repo root: -`MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` +`MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` ## Commands diff --git a/examples/servers/simple-auth-multiprotocol/README.md b/examples/servers/simple-auth-multiprotocol/README.md index 4504e08ff..fe7a2a94b 100644 --- a/examples/servers/simple-auth-multiprotocol/README.md +++ b/examples/servers/simple-auth-multiprotocol/README.md @@ -36,7 +36,7 @@ You can run the Resource Server **without** the Authorization Server when using 3. At the `mcp>` prompt, run `list`, `call get_time {}`, then `quit`. **One-command verification** (from repo root): -`MCP_PHASE2_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +`MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` This starts the RS, then the client with API Key; complete the session with `list`, `call get_time {}`, `quit`. ## Running with DPoP (OAuth + DPoP) @@ -66,7 +66,7 @@ Mutual TLS is a **placeholder** in this example: the server accepts the `mutual_ - **Server**: No extra flags; `auth_protocols` already includes `mutual_tls`. - **Client** (from repo root): - `MCP_PHASE2_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` + `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` The client will start but mTLS authentication is not implemented in this example. ## Options From 01236d437ce90c37bef41ad3c5efa1ce6d326eea Mon Sep 17 00:00:00 2001 From: nypdmax Date: Thu, 5 Feb 2026 22:45:01 +0800 Subject: [PATCH 44/64] docs(auth): refresh multiprotocol and client_credentials documentation --- docs/authorization-multiprotocol.md | 151 +++++++++--------- src/mcp/client/auth/auth-analysis.md | 4 +- .../auth/multi-protocol-refactoring-plan.md | 2 + 3 files changed, 81 insertions(+), 76 deletions(-) diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md index 41080505e..b3fd4d9d4 100644 --- a/docs/authorization-multiprotocol.md +++ b/docs/authorization-multiprotocol.md @@ -1,6 +1,6 @@ # Authorization: Multi-Protocol Extension -This document extends the [Authorization](authorization.md) topic with design purpose, implementation logic, usage and integration, test examples, and limitations for multi-protocol auth in the MCP Python SDK. +This document extends [Authorization](authorization.md) with the design rationale, implementation behavior, usage and integration, test examples, and limitations of multi-protocol authentication in the MCP Python SDK. **References (RFCs and specs):** @@ -19,16 +19,16 @@ This document extends the [Authorization](authorization.md) topic with design pu The multi-protocol authorization extension aims to: -1. **Support multiple auth schemes** — Allow a single MCP resource server to accept OAuth 2.0, API Key, and (optionally) Mutual TLS or other protocols, so different clients can use the most appropriate method (e.g. API Key for automation, OAuth for user-delegated access). -2. **Preserve backward compatibility** — Existing OAuth-only clients and servers (e.g. `OAuthClientProvider`, `simple-auth` / `simple-auth-client`) continue to work without change; multi-protocol is additive. -3. **Unify discovery and selection** — The server declares supported protocols and optional default/preferences; the client discovers them via standard metadata (PRM, WWW-Authenticate) and selects one without hard-coding a single scheme. -4. **Enable optional DPoP** — When using OAuth, the client can bind the access token to a proof-of-possession key (DPoP, RFC 9449) to reduce token theft and replay risk. +1. **Support multiple auth schemes** — Allow a single MCP resource server to accept OAuth 2.0, API Key, and (optionally) Mutual TLS or other protocols, so that clients can choose the most appropriate method (e.g. API Key for automation, OAuth for user-delegated access). +2. **Preserve backward compatibility** — Existing OAuth-only clients and servers (e.g. `OAuthClientProvider`, `simple-auth` / `simple-auth-client`) continue to work unchanged; multi-protocol support is additive. +3. **Unify discovery and selection** — The server declares supported protocols and optional default and preferences; the client discovers them via standard metadata (PRM, WWW-Authenticate) and selects one without hardcoding a single scheme. +4. **Enable optional DPoP** — When using OAuth, the client may bind the access token to a proof-of-possession key (DPoP, RFC 9449) to reduce token theft and replay risk. ### 1.2 Non-goals - **Replacing OAuth** — OAuth remains the primary protocol for user-delegated access; API Key and mTLS are alternatives for machine or certificate-based auth. -- **Implementing full mTLS in examples** — The current examples use an mTLS *placeholder* (protocol declared, no real client certificate validation) to show protocol selection only. -- **Defining new HTTP auth schemes** — We use existing schemes (Bearer, DPoP, X-API-Key) and extend 401/403 response parameters (e.g. `auth_protocols`, `resource_metadata`) as in RFC 9728 and MCP conventions. +- **Implementing full mTLS in examples** — The current examples use an mTLS *placeholder* (protocol declared, no real client certificate validation) only to demonstrate protocol selection. +- **Defining new HTTP auth schemes** — The implementation uses existing schemes (Bearer, DPoP, X-API-Key) and extends 401/403 response parameters (e.g. `auth_protocols`, `resource_metadata`) as defined in RFC 9728 and MCP conventions. --- @@ -38,25 +38,25 @@ The multi-protocol authorization extension aims to: Discovery answers: *Which auth protocols does this resource support, and where is their metadata?* -**Sources (in order of use):** +**Sources (in priority order):** -1. **WWW-Authenticate on 401** — The resource server may include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, `protocol_preferences` (MCP extensions). See RFC 6750 (Bearer) and RFC 9728 (resource metadata). -2. **Protected Resource Metadata (PRM)** — RFC 9728 defines `/.well-known/oauth-protected-resource` (optionally with path). PRM JSON includes `authorization_servers`, and the SDK extends it with `mcp_auth_protocols`, `mcp_default_auth_protocol`, `mcp_auth_protocol_preferences`. -3. **Unified discovery endpoint** — `/.well-known/authorization_servers` returns a list of protocol metadata (MCP-style). The client tries path-relative first, then root (see protocol discovery order below). +1. **WWW-Authenticate on 401** — The resource server may include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, and `protocol_preferences` (MCP extensions). See RFC 6750 (Bearer) and RFC 9728 (resource metadata). +2. **Protected Resource Metadata (PRM)** — RFC 9728 defines `/.well-known/oauth-protected-resource` (optionally with a path suffix). PRM JSON includes `authorization_servers`; the SDK extends it with `mcp_auth_protocols`, `mcp_default_auth_protocol`, and `mcp_auth_protocol_preferences`. +3. **Unified discovery endpoint** — `/.well-known/authorization_servers` returns a list of protocol metadata (MCP-style). The client tries the path-relative URL first, then the root URL (see protocol discovery order below). **Protocol discovery order (priority):** -1. **Priority 1: PRM `mcp_auth_protocols`** — If PRM was obtained and contains `mcp_auth_protocols`, use that list. +1. **Priority 1: PRM `mcp_auth_protocols`** — If the PRM was obtained and contains `mcp_auth_protocols`, use that list. 2. **Priority 2: Path-relative unified discovery** — `{origin}/.well-known/authorization_servers{resource_path}` (e.g. `http://localhost:8002/.well-known/authorization_servers/mcp`). 3. **Priority 3: Root unified discovery** — `{origin}/.well-known/authorization_servers`. -4. **Priority 4: OAuth fallback** — If unified discovery failed and PRM has `authorization_servers`, attempt OAuth protocol discovery. +4. **Priority 4: OAuth fallback** — If unified discovery returns no protocol list and the PRM has `authorization_servers`, fall back to OAuth protocol discovery. **Client-side logic (high level):** - On 401, extract `resource_metadata` from WWW-Authenticate. -- Build PRM URLs: (1) `resource_metadata` if present, (2) path-based `/.well-known/oauth-protected-resource{path}`, (3) root `/.well-known/oauth-protected-resource`. Request each until PRM is obtained. -- For protocol list: if PRM has `mcp_auth_protocols`, use it (priority 1). Else try path-relative `/.well-known/authorization_servers{path}`, then root `/.well-known/authorization_servers`. If both fail and PRM has `authorization_servers`, use OAuth fallback. -- Combine protocol list with WWW-Authenticate `auth_protocols` if present, then select one via `AuthProtocolRegistry.select_protocol(available, default_protocol, preferences)`. +- Build PRM URLs: (1) `resource_metadata` if present, (2) path-based `/.well-known/oauth-protected-resource{path}`, (3) root `/.well-known/oauth-protected-resource`. Request each in turn until a PRM response is obtained. +- For the protocol list: if the PRM has `mcp_auth_protocols`, use it (priority 1). Otherwise try path-relative `/.well-known/authorization_servers{path}`, then root `/.well-known/authorization_servers`. If both fail and the PRM has `authorization_servers`, use OAuth fallback. +- Merge the protocol list with WWW-Authenticate `auth_protocols` if present, then select one via `AuthProtocolRegistry.select_protocol(available, default_protocol, preferences)`. **Relationship between authorization URL endpoints** @@ -90,12 +90,12 @@ MCP Resource Server (http://localhost:8002) **Client discovery order** 1. On 401, read `resource_metadata` from WWW-Authenticate (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). -2. If absent, try path-based: `{origin}/.well-known/oauth-protected-resource{resource_path}` (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). -3. If absent, try root: `{origin}/.well-known/oauth-protected-resource`. -4. PRM includes `authorization_servers` (AS URL) and `mcp_auth_protocols`; for OAuth, the client then fetches `{AS}/.well-known/oauth-authorization-server`. -5. For protocol list (in order): (1) If PRM has `mcp_auth_protocols`, use it. (2) Else try path-relative `{origin}/.well-known/authorization_servers{resource_path}` (e.g. `http://localhost:8002/.well-known/authorization_servers/mcp`). (3) Else try root `{origin}/.well-known/authorization_servers`. (4) If all fail and PRM has `authorization_servers`, use OAuth fallback. +2. If absent, try the path-based URL: `{origin}/.well-known/oauth-protected-resource{resource_path}` (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). +3. If still absent, try the root URL: `{origin}/.well-known/oauth-protected-resource`. +4. The PRM includes `authorization_servers` (AS URL) and optionally `mcp_auth_protocols`; for OAuth, the client then fetches `{AS}/.well-known/oauth-authorization-server`. +5. For the protocol list, in order: (1) If the PRM has `mcp_auth_protocols`, use it. (2) Otherwise try path-relative `{origin}/.well-known/authorization_servers{resource_path}` (e.g. `http://localhost:8002/.well-known/authorization_servers/mcp`). (3) Otherwise try root `{origin}/.well-known/authorization_servers`. (4) If all fail and the PRM has `authorization_servers`, use OAuth fallback. -**Auth discovery logging:** When discovery runs, the SDK emits debug-level logs (English, `[Auth discovery]` prefix) for each PRM and unified-discovery request: URL, status code, and (on 200) pretty-printed response body. Set `LOG_LEVEL=DEBUG` on the client to see them. Implemented in `mcp.client.auth.utils` (`format_json_for_logging`, `handle_protected_resource_response`, `discover_authorization_servers`) and `mcp.client.auth.multi_protocol` (`_parse_protocols_from_discovery_response`, `async_auth_flow`). +**Auth discovery logging:** When discovery runs, the SDK emits debug-level logs with the `[Auth discovery]` prefix for each PRM and unified-discovery request (URL, status code, and on 200 a pretty-printed response body). Set `LOG_LEVEL=DEBUG` on the client to enable them. Implemented in `mcp.client.auth.utils` (`format_json_for_logging`, `handle_protected_resource_response`, `discover_authorization_servers`) and `mcp.client.auth.multi_protocol` (`_parse_protocols_from_discovery_response`, `async_auth_flow`). **References:** RFC 9728 (PRM), RFC 8414 (OAuth AS metadata), SDK `mcp.client.auth.utils` (`build_protected_resource_metadata_discovery_urls`, `discover_authorization_servers`). @@ -126,19 +126,19 @@ flowchart LR ### 2.2 MCP client logic -The client uses **MultiProtocolAuthProvider** (httpx.Auth) so that every HTTP request is prepared and 401/403 are handled in one place. +The client uses **MultiProtocolAuthProvider** (httpx.Auth) to prepare each HTTP request and to handle 401/403 in one place. **Main flow:** -1. **Initialization** — On first use, `_initialize()` runs (e.g. register protocol classes). No network call yet. -2. **Before first request** — Read credentials from `TokenStorage` (`get_tokens` → `AuthCredentials | OAuthToken`). If present and valid (`protocol.validate_credentials`), call `protocol.prepare_request(request, credentials)` and, if DPoP is enabled and the protocol supports it, attach a DPoP proof. Then yield the request. -3. **On 401** — (See discovery above.) After obtaining protocols and selecting one: - - **If OAuth2:** Build an `OAuthClientProvider` from config and drive the shared **oauth_401_flow_generator** (all OAuth steps — AS discovery, registration, authorization, token exchange — are done by yielding requests; httpx sends them and injects responses back). No extra HTTP client; avoids lock deadlock. - - **If API Key / other:** Build `AuthContext`, call `protocol.authenticate(context)`, then store credentials and retry the original request with `prepare_request`. -4. **On 403** — Parse `error` / `scope` from WWW-Authenticate for logging; current implementation does not auto-retry. -5. **TokenStorage contract** — Storage may return `OAuthToken` or `AuthCredentials`. The provider converts OAuthToken → OAuthCredentials when reading and OAuthCredentials → OAuthToken when writing so that OAuth-only storage remains usable. +1. **Initialization** — On first use, `_initialize()` runs (e.g. to register protocol classes). No network calls are made at this stage. +2. **Before first request** — Read credentials from `TokenStorage` (`get_tokens` → `AuthCredentials | OAuthToken`). If credentials are present and valid (`protocol.validate_credentials`), call `protocol.prepare_request(request, credentials)` and, if DPoP is enabled and the protocol supports it, attach a DPoP proof; then yield the request. +3. **On 401** — (See discovery above.) After obtaining the protocol list and selecting a protocol: + - **If OAuth2:** Build an `OAuthClientProvider` from config and drive the shared **oauth_401_flow_generator** (AS discovery, registration, authorization, and token exchange are performed by yielding requests that httpx sends and whose responses are injected back; no separate HTTP client is used, which avoids deadlock). OAuth2 supports two grant types in this flow: **authorization_code** (redirect/callback) and **client_credentials** (machine-to-machine). For **client_credentials**, the client supplies **fixed_client_info** (client_id, client_secret), so no dynamic registration is performed; the provider calls the token endpoint with `grant_type=client_credentials`. + - **If API Key or other:** Build `AuthContext`, call `protocol.authenticate(context)`, store the returned credentials, then retry the original request with `prepare_request`. +4. **On 403** — The client parses `error` and `scope` from WWW-Authenticate for logging; the current implementation does not retry automatically. +5. **TokenStorage contract** — The storage backend may return `OAuthToken` or `AuthCredentials`. The provider converts OAuthToken → OAuthCredentials when reading and OAuthCredentials → OAuthToken when writing, so that backends that support only OAuthToken remain usable. -**Protocol selection** — `AuthProtocolRegistry.select_protocol(available_protocols, default_protocol, protocol_preferences)` filters to registered protocols, then applies default and then preference order (lower number = higher priority). +**Protocol selection** — `AuthProtocolRegistry.select_protocol(available_protocols, default_protocol, protocol_preferences)` restricts the choice to registered protocols, then applies the default protocol and the preference order (lower numeric value denotes higher priority). **References:** `mcp.client.auth.multi_protocol` (MultiProtocolAuthProvider, async_auth_flow), `mcp.client.auth._oauth_401_flow` (oauth_401_flow_generator), `mcp.client.auth.registry` (AuthProtocolRegistry), `mcp.client.auth.protocol` (AuthProtocol, DPoPEnabledProtocol). @@ -176,7 +176,7 @@ sequenceDiagram ### 2.3 MCP server logic -The server exposes protected MCP endpoints and declares supported auth methods via PRM and (optionally) unified discovery; it then verifies credentials on each request. +The server exposes protected MCP endpoints and declares supported auth methods via PRM and (optionally) unified discovery; credentials are verified on each request. **Routes and metadata:** @@ -184,32 +184,32 @@ The server exposes protected MCP endpoints and declares supported auth methods v 2. **Unified discovery** — `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` registers `/.well-known/authorization_servers`. The handler returns `{ "protocols": [ AuthProtocolMetadata, ... ] }` plus optional default and preferences. 3. **401 responses** — Middleware (e.g. RequireAuthMiddleware) returns 401 with WWW-Authenticate including at least Bearer (and optionally `resource_metadata`, `auth_protocols`, `default_protocol`, `protocol_preferences`). -**Configuration and URL tree — what each server must provide** +**Configuration and URL tree — requirements by server type** -**Authorization Server (AS) — required configuration /改造:** +**Authorization Server (AS) — configuration requirements** | Item | Description | |------|-------------| -| `/.well-known/oauth-authorization-server` | **Must expose** (RFC 8414). Returns JSON with `authorization_endpoint`, `token_endpoint`, optionally `registration_endpoint`, `scopes_supported`. | -| `/authorize`, `/token` | **Must implement** — OAuth authorization code + PKCE flow. | +| `/.well-known/oauth-authorization-server` | **Must expose** (RFC 8414). Returns JSON with `authorization_endpoint`, `token_endpoint`, and optionally `registration_endpoint`, `scopes_supported`. | +| `/authorize`, `/token` | **Must implement** — OAuth authorization code flow with PKCE. | | `/register` | Optional — dynamic client registration. | -| `/introspect` | **Required if RS uses introspection** — RS calls this to validate Bearer/DPoP tokens. | -| **DPoP** | If DPoP is used: tokens must include `cnf` (e.g. `jkt`) so the RS can verify the DPoP proof. | +| `/introspect` | **Required if the RS uses introspection** — The RS calls this endpoint to validate Bearer/DPoP tokens. | +| **DPoP** | If DPoP is used, tokens must include `cnf` (e.g. `jkt`) so the RS can verify the DPoP proof. | -**No change** to the AS is needed for *multi-protocol* itself. The AS only needs to support standard OAuth 2.0 and (optionally) DPoP-bound tokens. +No changes to the AS are required for multi-protocol itself; the AS need only support standard OAuth 2.0 and (optionally) DPoP-bound tokens. -**MCP Resource Server (RS) — required configuration /改造:** +**MCP Resource Server (RS) — configuration requirements** | Item | Description | |------|-------------| -| `resource_url` | Base URL of the protected resource (e.g. `http://localhost:8002/mcp`). Used to build PRM path: `/.well-known/oauth-protected-resource{path}`. | -| `authorization_servers` | List of AS URLs (e.g. `["http://localhost:9000"]`). PRM references these so clients know where to get tokens. | +| `resource_url` | Base URL of the protected resource (e.g. `http://localhost:8002/mcp`). Used to build the PRM path: `/.well-known/oauth-protected-resource{path}`. | +| `authorization_servers` | List of AS URLs (e.g. `["http://localhost:9000"]`). PRM references these so that clients know where to obtain tokens. | | `auth_protocols` | List of `AuthProtocolMetadata` (protocol_id, protocol_version, metadata_url for OAuth, etc.). | | `default_protocol` | Optional default protocol ID (e.g. `"oauth2"`). | | `protocol_preferences` | Optional priority map (e.g. `{"oauth2": 1, "api_key": 2}`). | -| PRM route | Mount `create_protected_resource_routes(...)` so `/.well-known/oauth-protected-resource{path}` is served. Path is derived from `resource_url` (e.g. `/mcp` → `/.well-known/oauth-protected-resource/mcp`). | -| Unified discovery route | Mount `create_authorization_servers_discovery_routes(...)` so `/.well-known/authorization_servers` is served. Recommended at **origin root** (e.g. `http://localhost:8002/.well-known/authorization_servers`) so clients using path-relative discovery can fall back to PRM when 404. | -| WWW-Authenticate on 401 | Include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, `protocol_preferences` so MCP clients can discover without extra requests. | +| PRM route | Mount `create_protected_resource_routes(...)` so that `/.well-known/oauth-protected-resource{path}` is served. The path is derived from `resource_url` (e.g. `/mcp` → `/.well-known/oauth-protected-resource/mcp`). | +| Unified discovery route | Mount `create_authorization_servers_discovery_routes(...)` so that `/.well-known/authorization_servers` is served. Serving at **origin root** (e.g. `http://localhost:8002/.well-known/authorization_servers`) is recommended so that clients that try path-relative discovery first can fall back when that endpoint returns 404. | +| WWW-Authenticate on 401 | Include `resource_metadata` (PRM URL), `auth_protocols`, `default_protocol`, and `protocol_preferences` so that MCP clients can discover protocols without additional requests. | **Example config (simple-auth-multiprotocol):** @@ -243,10 +243,10 @@ protocol_preferences = {"oauth2": 1, "api_key": 2, "mutual_tls": 3} **Verification:** -1. **MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier` instances. For each request it calls `verifier.verify(request, dpop_verifier)` in order; the first non-None result wins. -2. **OAuthTokenVerifier** — Reads `Authorization: Bearer ` or `Authorization: DPoP `. Verifies the token (e.g. introspection); if DPoP-bound and `dpop_verifier` is set, validates the DPoP proof (method, URI, `ath`, jti replay). See RFC 9449. -3. **APIKeyVerifier** — Reads `X-API-Key` first, then falls back to `Authorization: Bearer ` and checks the value in `valid_keys`. Does not parse an `ApiKey` scheme. -4. **DPoP** — When enabled, the backend is constructed with a `DPoPProofVerifier` and passes it into each verifier. The verifier uses it only when the token is DPoP-bound (e.g. `Authorization: DPoP` and valid proof). +1. **MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier` instances. For each request it calls `verifier.verify(request, dpop_verifier)` in order; the first successful (non-None) result is used. +2. **OAuthTokenVerifier** — Reads `Authorization: Bearer ` or `Authorization: DPoP `. Verifies the token (e.g. via introspection); if the token is DPoP-bound and `dpop_verifier` is set, it validates the DPoP proof (method, URI, `ath`, jti replay). See RFC 9449. +3. **APIKeyVerifier** — Reads `X-API-Key` first, then falls back to `Authorization: Bearer ` and checks the value against `valid_keys`. It does not parse an `ApiKey` scheme. +4. **DPoP** — When enabled, the backend is constructed with a `DPoPProofVerifier` and passes it into each verifier. The verifier uses it only when the token is DPoP-bound (e.g. `Authorization: DPoP` with a valid proof). **References:** `mcp.server.auth.routes` (create_protected_resource_routes, create_authorization_servers_discovery_routes), `mcp.server.auth.verifiers` (MultiProtocolAuthBackend, OAuthTokenVerifier, APIKeyVerifier), `mcp.server.auth.dpop` (DPoPProofVerifier). @@ -279,9 +279,10 @@ When the resource server uses **OAuth 2.0** as one of the protocols: 1. **Expose OAuth 2.0 metadata** — RFC 8414: `/.well-known/oauth-authorization-server` (or `/.well-known/openid-configuration` if applicable). Must include `authorization_endpoint`, `token_endpoint`, and optionally `registration_endpoint`, `scopes_supported`. 2. **Support authorization code + PKCE** — Authorization endpoint, token endpoint, and (optional) dynamic client registration. MCP clients use authorization code with PKCE and optionally DPoP-bound tokens. -3. **Token introspection (if used)** — Resource server may call the AS introspection endpoint to validate Bearer/DPoP tokens. DPoP-bound tokens require the AS to include `cnf` (e.g. `jkt`) in the token or introspection response so the RS can verify the DPoP proof (RFC 9449). +3. **Client credentials grant (optional)** — If the resource server advertises OAuth2 and clients may use the **client_credentials** grant (e.g. machine-to-machine), the AS must include `client_credentials` in `grant_types_supported` in its metadata and implement the token endpoint for `grant_type=client_credentials`. +4. **Token introspection (if used)** — The resource server may call the AS introspection endpoint to validate Bearer/DPoP tokens. For DPoP-bound tokens, the AS must include `cnf` (e.g. `jkt`) in the token or introspection response so the RS can verify the DPoP proof (RFC 9449). -No change is required to the AS for *multi-protocol* itself; the AS only needs to support the OAuth flows and (if DPoP is used) token binding. +No changes to the AS are required for multi-protocol itself; the AS need only support the OAuth flows and (if DPoP is used) token binding. ### 3.2 MCP client responsibilities @@ -289,13 +290,13 @@ No change is required to the AS for *multi-protocol* itself; the AS only needs t 2. **Register protocols** — Call `AuthProtocolRegistry.register(protocol_id, ProtocolClass)` for each supported protocol (e.g. `oauth2`, `api_key`) before creating the provider. 3. **Storage** — Provide a `TokenStorage` that implements `get_tokens()` → `AuthCredentials | OAuthToken | None` and `set_tokens(AuthCredentials | OAuthToken)`. For OAuth-only storage, the provider converts to/from OAuthToken internally; or use an adapter. 4. **Provider configuration** — Construct `MultiProtocolAuthProvider` with storage, optional `dpop_enabled`, optional `dpop_storage`, and (for 401 flows) an `http_client` that will be used to send yielded requests. Attach the provider as `httpx.Client(auth=provider)`. -5. **Environment / config** — For the example client: `MCP_SERVER_URL`, `MCP_API_KEY` (API Key), `MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1` (OAuth + DPoP). Protocol selection is driven by server discovery and registry. +5. **Environment / config** — For the example client: `MCP_SERVER_URL`, `MCP_API_KEY` (API Key), `MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1` (OAuth + DPoP). Protocol selection is determined by server discovery and the registry. ### 3.3 MCP server (resource server) responsibilities 1. **Define protocols** — Build a list of `AuthProtocolMetadata` (protocol_id, protocol_version, metadata_url for OAuth, etc.) and optionally `default_protocol` and `protocol_preferences`. 2. **Mount PRM** — Call `create_protected_resource_routes(resource_url, authorization_servers, ..., auth_protocols=auth_protocols, default_protocol=..., protocol_preferences=...)` and mount the returned routes so that `/.well-known/oauth-protected-resource{path}` serves PRM JSON (RFC 9728 + MCP extensions). -3. **Mount unified discovery (optional)** — Call `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` and mount so that `/.well-known/authorization_servers` returns the protocol list. +3. **Mount unified discovery (optional)** — Call `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` and mount the returned routes so that `/.well-known/authorization_servers` returns the protocol list. 4. **Build backend** — Instantiate `OAuthTokenVerifier`, `APIKeyVerifier`, and (if needed) other verifiers; pass them into `MultiProtocolAuthBackend`. If DPoP is used, create a `DPoPProofVerifier` and pass it into `backend.verify(request, dpop_verifier=...)`. 5. **401/403 responses** — Use middleware that returns 401 with WWW-Authenticate (Bearer at minimum; add `resource_metadata`, `auth_protocols`, `default_protocol`, `protocol_preferences` for MCP clients). Optionally return 403 with `error` and `scope` when appropriate. @@ -320,7 +321,7 @@ Client-side protocol interface. All auth protocols (OAuth2, API Key, etc.) must #### CredentialVerifier (`mcp.server.auth.verifiers`) -Server-side verifier interface. Each verifier checks one auth scheme. +Server-side verifier interface. Each verifier validates a single auth scheme. | Member | Type | Description | |--------|------|-------------| @@ -331,7 +332,7 @@ Server-side verifier interface. Each verifier checks one auth scheme. - **OAuthTokenVerifier** — Reads `Authorization: Bearer ` or `Authorization: DPoP `. Verifies token (e.g. introspection); if DPoP-bound and `dpop_verifier` is set, validates DPoP proof. - **APIKeyVerifier** — Reads `X-API-Key` first, then `Authorization: Bearer ` if value is in `valid_keys`. Constructor: `APIKeyVerifier(valid_keys: set[str], scopes: list[str] | None = None)`. -**MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier`; calls them in order; first non-None result wins. +**MultiProtocolAuthBackend** — Holds a list of `CredentialVerifier` instances; calls them in order; the first successful (non-None) result is used. #### TokenStorage (multi-protocol contract) @@ -340,16 +341,16 @@ Server-side verifier interface. Each verifier checks one auth scheme. | `get_tokens()` | `async def` → `AuthCredentials \| OAuthToken \| None` | Return stored credentials. | | `set_tokens(tokens)` | `async def` | Store `AuthCredentials` or `OAuthToken`. | -For OAuth-only storage, the provider converts between `OAuthToken` and `OAuthCredentials` internally; no adapter required. +For storage backends that support only OAuthToken, the provider converts between `OAuthToken` and `OAuthCredentials` internally; no adapter is required. ### 3.5 Migration from OAuth-only: step-by-step guide -If you currently use `OAuthClientProvider` / `simple-auth-client` and want to add multi-protocol (e.g. API Key or OAuth + DPoP): +If you use `OAuthClientProvider` or `simple-auth-client` and want to add multi-protocol support (e.g. API Key or OAuth + DPoP): -#### Step 1: Keep OAuth-only path (no change) +#### Step 1: Keep the OAuth-only path unchanged -- `OAuthClientProvider`, `simple-auth`, `simple-auth-client` remain as-is. -- No code changes if you only use OAuth. +- `OAuthClientProvider`, `simple-auth`, and `simple-auth-client` continue to work as before. +- No code changes are required if you use only OAuth. #### Step 2: Client — switch to MultiProtocolAuthProvider @@ -384,7 +385,7 @@ provider._http_client = client **If your storage only handles OAuthToken:** - No change required; the provider converts internally. -- Or use `OAuthTokenStorageAdapter` to wrap an OAuth-only storage. +- Alternatively, use `OAuthTokenStorageAdapter` to wrap storage that supports only OAuthToken. **If you add API Key:** ```python @@ -467,12 +468,12 @@ See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these a - Starts the multi-protocol resource server (`simple-auth-multiprotocol-rs`) on port 8002 with `--api-keys=demo-api-key-12345`. For OAuth, also starts the AS (`simple-auth-as`) on port 9000. - Waits for PRM: `GET http://localhost:8002/.well-known/oauth-protected-resource/mcp`. -- Runs the client according to `MCP_AUTH_PROTOCOL`: - - **api_key** (default): `simple-auth-multiprotocol-client` with `MCP_SERVER_URL=http://localhost:8002/mcp` and `MCP_API_KEY=demo-api-key-12345`. No AS required. - - **oauth**: `simple-auth-client` against the same RS; user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. - - **mutual_tls**: same multiprotocol client without API key; mTLS is a placeholder (no real client cert). +- Runs the client based on `MCP_AUTH_PROTOCOL`: + - **api_key** (default): `simple-auth-multiprotocol-client` with `MCP_SERVER_URL=http://localhost:8002/mcp` and `MCP_API_KEY=demo-api-key-12345`. No AS is required. + - **oauth**: `simple-auth-client` against the same RS; the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. + - **mutual_tls**: the same multiprotocol client without an API key; mTLS is a placeholder (no real client certificate validation). -**What it demonstrates:** PRM and optional unified discovery, protocol selection (API Key vs OAuth), and that API Key works without an AS. +**Demonstrates:** PRM and optional unified discovery, protocol selection (API Key vs OAuth), and API Key authentication without an AS. ### 4.2 Phase 4: DPoP integration @@ -485,11 +486,11 @@ See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these a - **B2:** API Key request → 200 (DPoP does not affect API Key). - **A2:** Bearer token without DPoP proof → 401 (RS requires DPoP when token is DPoP-bound). - Negative: fake token, wrong htm/htu, DPoP without Authorization → 401. -- Optionally runs **manual** OAuth+DPoP client test: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1` with the multiprotocol client; user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". +- Optionally runs a **manual** OAuth+DPoP client test: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1` with the multiprotocol client; the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". -**Env:** `MCP_SKIP_OAUTH=1` skips the manual client step and only runs the automated curl tests. +**Env:** `MCP_SKIP_OAUTH=1` skips the manual client step and runs only the automated curl tests. -**What it demonstrates:** DPoP proof verification on the server, rejection of Bearer without proof when DPoP is required, and successful OAuth+DPoP flow with the example client. +**Demonstrates:** DPoP proof verification on the server, rejection of Bearer tokens without a proof when DPoP is required, and a successful OAuth+DPoP flow with the example client. ### 4.3 Test matrix (reference) @@ -507,20 +508,20 @@ See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these a ### 5.1 Limitations -1. **Mutual TLS** — Only a placeholder: the protocol is advertised and selectable, but the example server does not perform client certificate validation. A full mTLS implementation would require TLS client cert handling and a verifier that checks the certificate. -2. **Unified discovery URL** — Discovery uses `/.well-known/authorization_servers` path-relative to the *resource* URL (e.g. `http://host:8002/mcp` → `http://host:8002/mcp/.well-known/authorization_servers`). Some servers may instead serve this at the origin (e.g. `http://host:8002/.well-known/authorization_servers`); fallback to PRM’s `mcp_auth_protocols` covers that. -3. **403 handling** — The client parses 403 WWW-Authenticate for logging but does not automatically retry with a new scope or token; that could be extended for specific error/scope values. -4. **DPoP nonce** — Server-side DPoP nonce (RFC 9449) is not yet used in the example; only jti replay protection is in place. Adding nonce would improve robustness against pre-replay. +1. **Mutual TLS** — Implemented as a placeholder only: the protocol is advertised and selectable, but the example server does not perform client certificate validation. A full mTLS implementation would require TLS client certificate handling and a verifier that validates the certificate. +2. **Unified discovery URL** — The client tries `/.well-known/authorization_servers` in path-relative form (e.g. `http://host:8002/.well-known/authorization_servers/mcp`) then at the origin root (e.g. `http://host:8002/.well-known/authorization_servers`). Servers may expose only one of these; the ordered try and fallback to PRM’s `mcp_auth_protocols` accommodate both. +3. **403 handling** — The client parses 403 WWW-Authenticate for logging but does not retry automatically with a new scope or token; behavior could be extended for specific error or scope values. +4. **DPoP nonce** — Server-side DPoP nonce (RFC 9449) is not yet implemented in the example; only jti replay protection is in place. Adding nonce would improve robustness against pre-replay. 5. **TokenStorage** — The dual contract (OAuthToken vs AuthCredentials) and in-memory conversion are documented; a formal adapter type or storage interface versioning could simplify integration for new backends. ### 5.2 Possible evolution -- **Full mTLS example** — Implement client certificate validation and a verifier that maps the client cert to an identity/scope. -- **Discovery flexibility** — Support configurable discovery URL templates or multiple well-known paths so both path-relative and origin-relative discovery work without relying only on PRM fallback. +- **Full mTLS example** — Add client certificate validation and a verifier that maps the client certificate to an identity and scope. +- **Discovery flexibility** — Allow configurable discovery URL templates or multiple well-known paths so that both path-relative and origin-relative discovery work without relying solely on PRM fallback. - **403 retry policy** — Define retry rules for 403 (e.g. `insufficient_scope`) and integrate with OAuth scope refresh or re-authorization. - **DPoP nonce** — Implement server-initiated nonce and client nonce handling per RFC 9449. --- **Related documentation:** [Authorization](authorization.md) (overview), [API Reference](api.md). -**Examples:** [simple-auth-multiprotocol](../examples/servers/simple-auth-multiprotocol/), [simple-auth-multiprotocol-client](../examples/clients/simple-auth-multiprotocol-client/), [examples/README.md](../examples/README.md). +**Examples:** [simple-auth-multiprotocol](../examples/servers/simple-auth-multiprotocol/) (includes the server variants **prm_only**, **path_only**, **root_only**, and **oauth_fallback** for testing each discovery path), [simple-auth-multiprotocol-client](../examples/clients/simple-auth-multiprotocol-client/), [examples/README.md](../examples/README.md). diff --git a/src/mcp/client/auth/auth-analysis.md b/src/mcp/client/auth/auth-analysis.md index cebcad434..2dea537a5 100644 --- a/src/mcp/client/auth/auth-analysis.md +++ b/src/mcp/client/auth/auth-analysis.md @@ -73,7 +73,9 @@ provider = OAuthClientProvider( ### 2.2 Client Credentials Flow(客户端凭证流程) -**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) +**多协议发现场景下的 client_credentials**:在统一鉴权服务发现(MultiProtocolAuthProvider)场景下,Client Credentials 作为 OAuth 2.0 的 **grant type** 集成在 `OAuth2Protocol` 中,而非独立协议。客户端通过 `OAuth2Protocol(grant_types=["client_credentials"], fixed_client_info=...)` 注入预注册的 client_id/client_secret,无需动态注册。授权服务器需在元数据中声明 `grant_types_supported` 包含 `client_credentials`,并在 token 端点处理 `grant_type=client_credentials` 请求。下文的 **ClientCredentialsOAuthProvider** 仍适用于不经过统一发现的独立使用场景。 + +**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py)(独立使用);[`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py)(MultiProtocolAuthProvider 下通过 OAuth2Protocol + fixed_client_info)。 **提供者类**: diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md index 5a82fe063..a2d344dea 100644 --- a/src/mcp/client/auth/multi-protocol-refactoring-plan.md +++ b/src/mcp/client/auth/multi-protocol-refactoring-plan.md @@ -31,6 +31,8 @@ - ❌ **Scope模型**(OAuth特定,除非新协议也有类似概念) - ❌ **OAuth客户端认证方法**(client_secret_basic等) +**说明**:Client Credentials 作为 OAuth 2.0 的 **grant type** 在现有 OAuth2 流程中实现(`OAuth2Protocol` + `fixed_client_info`),不单独新增协议;AS 需在 token 端点支持 `grant_type=client_credentials` 并在元数据中声明 `grant_types_supported`。 + ### 2.2 必须实现的功能(MCP通用) 以下功能是MCP授权规范要求的,所有协议都必须支持: From c3ca87c2e0bc9cde3d0757adb69c88ac0f5a70c2 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:10:44 +0800 Subject: [PATCH 45/64] chore(repo): align gitignore with main --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index ec70997b9..de1699559 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,6 @@ .DS_Store scratch/ -# Local plans and scratch docs (never commit) -/plans/ - -# Cursor AI assistant rules (local only) -/.cursorrules - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From bcda5001000ec3bf19efcbb7387c785c98b70d86 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:10:55 +0800 Subject: [PATCH 46/64] chore(auth): drop internal design docs from src --- src/mcp/client/auth/auth-analysis.md | 2932 ----------------- .../auth/multi-protocol-refactoring-plan.md | 1409 -------- 2 files changed, 4341 deletions(-) delete mode 100644 src/mcp/client/auth/auth-analysis.md delete mode 100644 src/mcp/client/auth/multi-protocol-refactoring-plan.md diff --git a/src/mcp/client/auth/auth-analysis.md b/src/mcp/client/auth/auth-analysis.md deleted file mode 100644 index 2dea537a5..000000000 --- a/src/mcp/client/auth/auth-analysis.md +++ /dev/null @@ -1,2932 +0,0 @@ -# MCP 客户端鉴权流程分析 - -本文档详细说明 MCP Python SDK 支持的 OAuth 2.0 鉴权协议、授权流程类型、客户端认证方法以及完整的鉴权流程。 - -## 一、支持的鉴权协议 - -MCP Python SDK 基于 **OAuth 2.0** 实现鉴权,支持以下协议和标准: - -### 1. 核心协议 - -- **OAuth 2.0** (RFC 6749) - 主要鉴权协议框架 -- **PKCE** (RFC 7636) - 授权码流程的安全扩展,使用 S256 方法 -- **RFC 8414** - OAuth 2.0 授权服务器元数据发现 -- **RFC 9728** - OAuth 2.0 受保护资源元数据(PRM) -- **RFC 8707** - 资源参数扩展,用于多资源场景 -- **RFC 7523** - JWT Bearer Token 授权(部分支持,主要用于客户端认证) -- **RFC 7591** - OAuth 2.0 动态客户端注册(DCR) - -### 2. 元数据发现机制 - -SDK 实现了完整的元数据发现流程,支持以下发现机制: - -- **受保护资源元数据发现** (RFC 9728) - - 从 `WWW-Authenticate` 头提取 `resource_metadata` 参数 - - 回退到路径感知的 well-known URI: `/.well-known/oauth-protected-resource/{path}` - - 回退到根路径的 well-known URI: `/.well-known/oauth-protected-resource` - -- **授权服务器元数据发现** (RFC 8414) - - 路径感知的 OAuth 发现: `/.well-known/oauth-authorization-server{path}` - - OIDC 发现端点回退: `/.well-known/openid-configuration{path}` 或 `{path}/.well-known/openid-configuration` - - 根路径回退: `/.well-known/oauth-authorization-server` 或 `/.well-known/openid-configuration` - -## 二、支持的授权流程(Grant Types) - -### 2.1 Authorization Code Flow(授权码流程) - -**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) - -**特点**: -- ✅ 使用 PKCE (S256) 增强安全性,防止授权码拦截攻击 -- ✅ 支持动态客户端注册(DCR) -- ✅ 支持 URL-based Client ID (CIMD) -- ✅ 自动令牌刷新 -- ✅ 支持权限升级(insufficient_scope 错误处理) - -**流程步骤**: -1. 生成 PKCE 参数(code_verifier 和 code_challenge) -2. 构建授权请求 URL,包含 state 参数用于防 CSRF -3. 通过 `redirect_handler` 重定向用户到授权端点 -4. 通过 `callback_handler` 接收授权码和 state -5. 使用授权码和 code_verifier 交换访问令牌 - -**代码示例**: -```python -from mcp.client.auth import OAuthClientProvider -from mcp.shared.auth import OAuthClientMetadata -from pydantic import AnyUrl - -client_metadata = OAuthClientMetadata( - redirect_uris=[AnyUrl("http://localhost:8080/callback")], - grant_types=["authorization_code"], - scope="read write" -) - -provider = OAuthClientProvider( - server_url="https://api.example.com", - client_metadata=client_metadata, - storage=token_storage, - redirect_handler=handle_redirect, - callback_handler=handle_callback -) -``` - -### 2.2 Client Credentials Flow(客户端凭证流程) - -**多协议发现场景下的 client_credentials**:在统一鉴权服务发现(MultiProtocolAuthProvider)场景下,Client Credentials 作为 OAuth 2.0 的 **grant type** 集成在 `OAuth2Protocol` 中,而非独立协议。客户端通过 `OAuth2Protocol(grant_types=["client_credentials"], fixed_client_info=...)` 注入预注册的 client_id/client_secret,无需动态注册。授权服务器需在元数据中声明 `grant_types_supported` 包含 `client_credentials`,并在 token 端点处理 `grant_type=client_credentials` 请求。下文的 **ClientCredentialsOAuthProvider** 仍适用于不经过统一发现的独立使用场景。 - -**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py)(独立使用);[`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py)(MultiProtocolAuthProvider 下通过 OAuth2Protocol + fixed_client_info)。 - -**提供者类**: - -#### ClientCredentialsOAuthProvider -使用传统的 `client_id` + `client_secret` 进行认证。 - -**支持的认证方法**: -- `client_secret_basic` - HTTP Basic Authentication(默认) -- `client_secret_post` - POST 表单认证 - -**代码示例**: -```python -from mcp.client.auth.extensions import ClientCredentialsOAuthProvider - -provider = ClientCredentialsOAuthProvider( - server_url="https://api.example.com", - storage=token_storage, - client_id="my-client-id", - client_secret="my-client-secret", - token_endpoint_auth_method="client_secret_basic", - scopes="read write" -) -``` - -#### PrivateKeyJWTOAuthProvider -使用 `private_key_jwt` 认证方法,通过 JWT 断言进行客户端认证(RFC 7523 Section 2.2)。 - -**使用场景**: -1. **预构建 JWT**(如 workload identity federation) - ```python - from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, static_assertion_provider - - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=token_storage, - client_id="my-client-id", - assertion_provider=static_assertion_provider(prebuilt_jwt) - ) - ``` - -2. **SDK 签名 JWT**(用于测试或简单部署) - ```python - from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, SignedJWTParameters - - jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, - signing_algorithm="RS256" - ) - - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=token_storage, - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider() - ) - ``` - -3. **Workload Identity Federation** - ```python - async def get_workload_identity_token(audience: str) -> str: - # 从身份提供者获取 JWT - return await fetch_token_from_identity_provider(audience=audience) - - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=token_storage, - client_id="my-client-id", - assertion_provider=get_workload_identity_token - ) - ``` - -### 2.3 Refresh Token Flow(刷新令牌流程) - -**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `_refresh_token()` 方法 - -**特点**: -- ✅ 自动检测令牌过期 -- ✅ 在令牌过期前自动刷新 -- ✅ 刷新失败时自动触发完整授权流程 -- ✅ 支持 RFC 8707 资源参数 - -**刷新条件**: -- 当前令牌无效或已过期 -- 存在有效的 refresh_token -- 存在客户端信息(client_info) - -### 2.4 JWT Bearer Grant(已弃用) - -**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) 的 `RFC7523OAuthClientProvider` - -**状态**: ⚠️ 已标记为 deprecated - -**建议**: 使用 `ClientCredentialsOAuthProvider` 或 `PrivateKeyJWTOAuthProvider` 替代 - -## 三、支持的客户端认证方法(Token Endpoint Auth Methods) - -### 3.1 client_secret_basic - -**实现位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `prepare_token_auth()` 方法 - -**认证方式**: HTTP Basic Authentication - -**实现细节**: -- 将 `client_id:client_secret` 进行 URL 编码 -- 使用 Base64 编码后放入 `Authorization` 头 -- 格式: `Authorization: Basic {base64(client_id:client_secret)}` -- 不在请求体中包含 `client_secret` - -**适用场景**: 标准的客户端凭证认证,安全性较高 - -### 3.2 client_secret_post - -**实现位置**: 同上 - -**认证方式**: POST 表单认证 - -**实现细节**: -- 在 POST 请求体的表单数据中包含 `client_secret` -- 同时包含 `client_id` 参数 -- 不使用 `Authorization` 头 - -**适用场景**: 某些不支持 HTTP Basic Auth 的服务器 - -### 3.3 private_key_jwt - -**实现位置**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) - -**认证方式**: JWT 断言认证(RFC 7523 Section 2.2) - -**实现细节**: -- 创建 JWT 断言,包含以下声明: - - `iss` (issuer): 客户端 ID - - `sub` (subject): 客户端 ID - - `aud` (audience): 授权服务器的 issuer 标识符 - - `exp` (expiration): 过期时间 - - `iat` (issued at): 签发时间 - - `jti` (JWT ID): 唯一标识符 -- 使用私钥签名 JWT -- 在请求体中包含: - - `client_assertion`: JWT 断言 - - `client_assertion_type`: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` - -**适用场景**: -- Workload identity federation -- 无共享密钥的场景 -- 需要更高安全性的企业环境 - -### 3.4 none - -**实现位置**: [`src/mcp/client/auth/utils.py`](src/mcp/client/auth/utils.py) 的 `create_client_info_from_metadata_url()` - -**认证方式**: 无客户端认证 - -**实现细节**: -- 不发送任何客户端凭证 -- 仅使用 URL-based Client ID (CIMD) -- `client_id` 为 HTTPS URL - -**适用场景**: URL-based Client ID (CIMD),当服务器支持 `client_id_metadata_document_supported=true` 时 - -## 四、完整鉴权流程 - -### 4.1 流程概述 - -MCP 客户端鉴权流程在收到 `401 Unauthorized` 响应时自动触发,遵循以下步骤: - -```mermaid -flowchart TD - Start([客户端发起请求]) --> CheckToken{检查存储的令牌} - CheckToken -->|有效| AddHeader[添加 Authorization 头] - CheckToken -->|无效但可刷新| RefreshToken[刷新令牌] - CheckToken -->|无效且不可刷新| SendRequest[发送请求] - - RefreshToken --> RefreshSuccess{刷新成功?} - RefreshSuccess -->|是| AddHeader - RefreshSuccess -->|否| SendRequest - - AddHeader --> SendRequest - SendRequest --> ReceiveResponse[接收响应] - - ReceiveResponse --> CheckStatus{检查状态码} - CheckStatus -->|200| Success([成功]) - CheckStatus -->|401| StartOAuth[启动 OAuth 流程] - CheckStatus -->|403| CheckScope{检查错误类型} - - CheckScope -->|insufficient_scope| StepUpAuth[权限升级授权] - CheckScope -->|其他| Error([错误]) - - StartOAuth --> DiscoverPRM[发现受保护资源元数据] - DiscoverPRM --> DiscoverOASM[发现 OAuth 授权服务器元数据] - DiscoverOASM --> SelectScope[选择 Scope] - SelectScope --> RegisterClient{客户端已注册?} - - RegisterClient -->|否| CheckCIMD{支持 CIMD?} - CheckCIMD -->|是| UseCIMD[使用 URL-based Client ID] - CheckCIMD -->|否| DCR[动态客户端注册] - RegisterClient -->|是| AuthFlow - - UseCIMD --> AuthFlow - DCR --> AuthFlow - - AuthFlow --> GrantType{授权流程类型} - GrantType -->|authorization_code| AuthCodeFlow[授权码流程 + PKCE] - GrantType -->|client_credentials| ClientCredFlow[客户端凭证流程] - - AuthCodeFlow --> ExchangeToken[交换令牌] - ClientCredFlow --> ExchangeToken - - ExchangeToken --> StoreToken[存储令牌] - StoreToken --> RetryRequest[重试原始请求] - RetryRequest --> Success - - StepUpAuth --> AuthCodeFlow -``` - -### 4.2 详细流程步骤 - -#### 阶段 1: 元数据发现 - -1. **提取受保护资源元数据 URL** - - 从 `WWW-Authenticate` 头提取 `resource_metadata` 参数 - - 如果不存在,使用回退 URL - -2. **发现受保护资源元数据(PRM)** - - 按照优先级尝试以下 URL: - 1. `WWW-Authenticate` 头中的 `resource_metadata` URL - 2. `/.well-known/oauth-protected-resource{path}`(路径感知) - 3. `/.well-known/oauth-protected-resource`(根路径) - - 解析 JSON 响应,获取 `authorization_servers` 列表 - -3. **发现 OAuth 授权服务器元数据(OASM)** - - 从 PRM 获取授权服务器 URL - - 按照优先级尝试以下发现端点: - 1. `/.well-known/oauth-authorization-server{path}` - 2. `/.well-known/openid-configuration{path}` - 3. `{path}/.well-known/openid-configuration` - 4. `/.well-known/oauth-authorization-server` - 5. `/.well-known/openid-configuration` - -4. **多协议客户端的协议发现顺序**(使用 `MultiProtocolAuthProvider` 时) - - 协议列表获取优先级:(1)PRM 的 `mcp_auth_protocols`(若已取得 PRM);(2)路径相对统一发现 `/.well-known/authorization_servers{path}`;(3)根路径统一发现 `/.well-known/authorization_servers`;(4)若均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退。 - - **鉴权发现日志**:发现过程在 `mcp.client.auth` 中输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志;客户端设置 `LOG_LEVEL=DEBUG` 可查看。 - -#### 阶段 2: Scope 选择 - -根据 MCP 规范的 Scope 选择策略,按以下优先级选择 scope: - -1. **优先级 1**: `WWW-Authenticate` 头中的 `scope` 参数 -2. **优先级 2**: PRM 的 `scopes_supported` 字段(所有 scope 用空格连接) -3. **优先级 3**: OASM 的 `scopes_supported` 字段 -4. **优先级 4**: 省略 scope 参数 - -#### 阶段 3: 客户端注册 - -1. **检查是否已注册** - - 从存储中加载客户端信息 - - 如果存在且有效,跳过注册步骤 - -2. **URL-based Client ID (CIMD)** - - 检查服务器是否支持:`client_id_metadata_document_supported == true` - - 检查是否提供了有效的 `client_metadata_url`(必须是 HTTPS URL 且路径不为根路径) - - 如果满足条件,使用 URL 作为 `client_id`,`token_endpoint_auth_method="none"` - -3. **动态客户端注册(DCR)** - - 如果 OASM 包含 `registration_endpoint`,执行 DCR - - 发送客户端元数据到注册端点 - - 接收并存储客户端信息(`client_id`、`client_secret` 等) - -#### 阶段 4: 授权流程 - -根据 `grant_types` 选择相应的授权流程: - -**Authorization Code Flow**: -1. 生成 PKCE 参数(code_verifier 和 code_challenge) -2. 生成随机 state 参数 -3. 构建授权 URL,包含: - - `response_type=code` - - `client_id` - - `redirect_uri` - - `state` - - `code_challenge` 和 `code_challenge_method=S256` - - `scope`(如果已选择) - - `resource`(如果协议版本 >= 2025-06-18 或存在 PRM) -4. 通过 `redirect_handler` 重定向用户 -5. 通过 `callback_handler` 接收授权码和 state -6. 验证 state 参数 - -**Client Credentials Flow**: -- 直接进入令牌交换阶段,无需用户交互 - -#### 阶段 5: 令牌交换 - -1. **构建令牌请求** - - 根据授权流程类型设置 `grant_type` - - 对于 authorization_code: 包含 `code`、`redirect_uri`、`code_verifier` - - 对于 client_credentials: 仅包含 `grant_type` - - 包含 `scope`(如果已选择) - - 包含 `resource`(如果满足条件) - -2. **客户端认证** - - 根据 `token_endpoint_auth_method` 准备认证信息: - - `client_secret_basic`: 在 `Authorization` 头中添加 Basic 认证 - - `client_secret_post`: 在请求体中添加 `client_secret` - - `private_key_jwt`: 生成并添加 JWT 断言 - - `none`: 不添加认证信息 - -3. **发送令牌请求** - - POST 请求到 `token_endpoint` - - 解析响应,获取 `access_token`、`refresh_token`、`expires_in` 等 - -4. **存储令牌** - - 计算令牌过期时间 - - 存储到 `TokenStorage` - - 更新上下文中的令牌信息 - -#### 阶段 6: 重试请求 - -- 使用新获取的访问令牌添加 `Authorization: Bearer {token}` 头 -- 重试原始请求 - -### 4.3 权限升级流程(403 insufficient_scope) - -当收到 `403 Forbidden` 且错误类型为 `insufficient_scope` 时: - -1. 从 `WWW-Authenticate` 头提取新的 scope 要求 -2. 更新客户端元数据中的 scope -3. 重新执行授权流程(通常是 Authorization Code Flow) -4. 使用新的 scope 交换令牌 -5. 重试原始请求 - -### 4.4 自动令牌刷新 - -在每次请求前,SDK 会: - -1. 检查当前令牌是否有效 -2. 如果无效但存在 `refresh_token`,自动刷新 -3. 刷新失败时,清除令牌并触发完整授权流程 - -## 五、特殊功能 - -### 5.1 动态客户端注册(DCR) - -**标准**: RFC 7591 - -**实现**: 自动检测服务器是否支持 DCR(通过检查 `registration_endpoint`),并执行注册 - -**优势**: -- 无需预先配置客户端凭证 -- 简化客户端部署 -- 支持临时客户端 - -**限制**: -- 服务器必须支持 DCR -- 需要提供完整的客户端元数据 - -### 5.2 URL-based Client ID (CIMD) - -**条件**: 服务器需支持 `client_id_metadata_document_supported=true` - -**优势**: -- 无需动态注册 -- 使用 HTTPS URL 作为 `client_id` -- 无需共享密钥(`token_endpoint_auth_method="none"`) -- 支持客户端元数据的去中心化管理 - -**要求**: -- `client_metadata_url` 必须是有效的 HTTPS URL -- URL 路径不能是根路径(`/`) - -### 5.3 元数据发现回退机制 - -**受保护资源元数据** (SEP-985): -- 支持多个回退 URL,提高兼容性 -- 自动尝试下一个 URL 如果当前 URL 返回 404 - -**授权服务器元数据**: -- 支持 OIDC 发现端点回退 -- 兼容仅支持 OIDC 发现的服务器 - -### 5.4 Scope 选择策略 - -**优先级顺序**: -1. `WWW-Authenticate` 头中的 `scope` 参数(最高优先级) -2. PRM 的 `scopes_supported` 字段 -3. OASM 的 `scopes_supported` 字段 -4. 省略 scope 参数(最低优先级) - -**实现位置**: [`src/mcp/client/auth/utils.py`](src/mcp/client/auth/utils.py) 的 `get_client_metadata_scopes()` 函数 - -### 5.5 资源参数支持(RFC 8707) - -**条件**: -- 协议版本 >= `2025-06-18`,或 -- 存在受保护资源元数据(PRM) - -**用途**: 在多资源场景中指定目标资源 - -**实现**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) 的 `should_include_resource_param()` 方法 - -### 5.6 PKCE 支持 - -**标准**: RFC 7636 - -**方法**: S256 (SHA256) - -**实现**: -- 自动生成 128 字符的 `code_verifier` -- 使用 SHA256 哈希并 Base64 URL 编码生成 `code_challenge` -- 在授权请求中包含 `code_challenge` 和 `code_challenge_method=S256` -- 在令牌交换时包含 `code_verifier` - -**优势**: 防止授权码拦截攻击,特别适用于公共客户端 - -## 六、关键组件 - -### 6.1 OAuthClientProvider - -**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) - -**职责**: -- 实现 `httpx.Auth` 协议 -- 管理完整的 OAuth 流程 -- 自动处理令牌刷新 -- 集成到 HTTPX 请求流程 - -**主要方法**: -- `async_auth_flow()`: HTTPX 认证流程入口 -- `_perform_authorization()`: 执行授权流程 -- `_refresh_token()`: 刷新访问令牌 -- `_initialize()`: 初始化,加载存储的令牌和客户端信息 - -### 6.2 OAuthContext - -**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) - -**职责**: -- 管理 OAuth 流程的上下文和状态 -- 存储发现的元数据 -- 管理令牌生命周期 -- 提供工具方法(如资源 URL 计算、令牌验证等) - -**关键属性**: -- `server_url`: MCP 服务器 URL -- `client_metadata`: 客户端元数据 -- `oauth_metadata`: OAuth 授权服务器元数据 -- `protected_resource_metadata`: 受保护资源元数据 -- `current_tokens`: 当前访问令牌 -- `client_info`: 客户端信息(client_id、client_secret 等) - -### 6.3 TokenStorage - -**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) - -**协议定义**: -```python -class TokenStorage(Protocol): - async def get_tokens(self) -> OAuthToken | None - async def set_tokens(self, tokens: OAuthToken) -> None - async def get_client_info(self) -> OAuthClientInformationFull | None - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None -``` - -**职责**: 提供令牌和客户端信息的持久化存储接口 - -**实现**: 用户需要实现此协议,可以使用内存、文件、数据库等存储方式 - -### 6.4 PKCEParameters - -**位置**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) - -**职责**: -- 生成 PKCE 参数 -- 验证参数格式 - -**生成方法**: -- `generate()`: 生成新的 PKCE 参数对 - -## 七、相关文件 - -### 核心实现 - -- **`src/mcp/client/auth/oauth2.py`** - 主要 OAuth 实现 - - `OAuthClientProvider`: 基础 OAuth 提供者 - - `OAuthContext`: OAuth 流程上下文 - - `PKCEParameters`: PKCE 参数管理 - - `TokenStorage`: 令牌存储协议 - -- **`src/mcp/client/auth/utils.py`** - 工具函数和元数据发现 - - 元数据发现 URL 构建 - - Scope 选择策略 - - 客户端注册请求构建 - - WWW-Authenticate 头解析 - -- **`src/mcp/client/auth/extensions/client_credentials.py`** - 客户端凭证扩展 - - `ClientCredentialsOAuthProvider`: 客户端凭证提供者 - - `PrivateKeyJWTOAuthProvider`: 私钥 JWT 认证提供者 - - `SignedJWTParameters`: JWT 签名参数 - - `RFC7523OAuthClientProvider`: JWT Bearer Grant(已弃用) - -### 数据模型 - -- **`src/mcp/shared/auth.py`** - OAuth 数据模型定义 - - `OAuthToken`: 访问令牌模型 - - `OAuthClientMetadata`: 客户端元数据(RFC 7591) - - `OAuthClientInformationFull`: 完整客户端信息 - - `OAuthMetadata`: 授权服务器元数据(RFC 8414) - - `ProtectedResourceMetadata`: 受保护资源元数据(RFC 9728) - -### 异常处理 - -- **`src/mcp/client/auth/exceptions.py`** - 鉴权相关异常 - - `OAuthFlowError`: OAuth 流程错误基类 - - `OAuthTokenError`: 令牌操作错误 - - `OAuthRegistrationError`: 客户端注册错误 - -## 八、使用示例 - -### 8.1 基本授权码流程 - -```python -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"请访问: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - # 在实际应用中,这应该从回调 URL 中提取 - auth_code = input("请输入授权码: ") - state = input("请输入 state (可选): ") or None - return auth_code, state - - -async def main(): - storage = InMemoryTokenStorage() - - client_metadata = OAuthClientMetadata( - redirect_uris=[AnyUrl("http://localhost:8080/callback")], - grant_types=["authorization_code"], - scope="read write" - ) - - provider = OAuthClientProvider( - server_url="https://api.example.com", - client_metadata=client_metadata, - storage=storage, - redirect_handler=handle_redirect, - callback_handler=handle_callback - ) - - async with httpx.AsyncClient(auth=provider) as client: - response = await client.get("https://api.example.com/api/data") - print(response.json()) - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### 8.2 客户端凭证流程 - -```python -from mcp.client.auth.extensions import ClientCredentialsOAuthProvider - -storage = InMemoryTokenStorage() - -provider = ClientCredentialsOAuthProvider( - server_url="https://api.example.com", - storage=storage, - client_id="my-client-id", - client_secret="my-client-secret", - token_endpoint_auth_method="client_secret_basic", - scopes="read write" -) - -async with httpx.AsyncClient(auth=provider) as client: - response = await client.get("https://api.example.com/api/data") -``` - -### 8.3 私钥 JWT 认证 - -```python -from mcp.client.auth.extensions import PrivateKeyJWTOAuthProvider, SignedJWTParameters - -# 使用 SDK 签名 JWT -jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, - signing_algorithm="RS256" -) - -provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=storage, - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider() -) -``` - -## 九、最佳实践 - -1. **令牌存储**: 使用安全的持久化存储(如加密的数据库或文件系统) -2. **错误处理**: 实现适当的错误处理和重试逻辑 -3. **安全性**: - - 始终使用 HTTPS - - 保护 client_secret 和私钥 - - 使用 PKCE 增强授权码流程安全性 -4. **令牌刷新**: 依赖 SDK 的自动刷新机制,但实现适当的错误处理 -5. **Scope 管理**: 仅请求必要的 scope,遵循最小权限原则 -6. **元数据缓存**: 考虑缓存元数据发现的结果以提高性能 - -## 十、故障排查 - -### 常见问题 - -1. **401 Unauthorized** - - 检查令牌是否过期 - - 验证客户端凭证是否正确 - - 确认 scope 是否足够 - -2. **403 Forbidden (insufficient_scope)** - - 检查请求的 scope - - 使用权限升级流程获取新令牌 - -3. **元数据发现失败** - - 检查服务器 URL 是否正确 - - 验证服务器是否支持 OAuth 2.0 - - 检查网络连接 - -4. **客户端注册失败** - - 验证客户端元数据是否完整 - - 检查服务器是否支持 DCR - - 确认 redirect_uris 格式正确 - -## 十一、OAuth 协议特性与 MCP 必需内容分析 - -本文档详细说明了 MCP Python SDK 的鉴权实现。为了帮助理解实现细节,本节将文档中的内容分为两类:**OAuth 协议自身特性**和**MCP 授权必需内容**。 - -### 11.1 OAuth 协议自身特性 - -这些是 OAuth 2.0/2.1 标准规范定义的内容,不特定于 MCP: - -#### 11.1.1 核心协议标准 -- **OAuth 2.0 (RFC 6749)** - 核心授权框架 -- **PKCE (RFC 7636)** - 授权码流程的安全扩展(OAuth 2.1 要求) -- **RFC 8414** - OAuth 2.0 授权服务器元数据发现(标准 OAuth 发现机制) -- **RFC 8707** - 资源参数扩展(多资源场景) -- **RFC 7523** - JWT Bearer Token 授权(客户端认证方法) -- **RFC 7591** - OAuth 2.0 动态客户端注册(DCR) - -#### 11.1.2 授权流程类型 -- **Authorization Code Flow(授权码流程)** - OAuth 2.0 标准流程 -- **Client Credentials Flow(客户端凭证流程)** - OAuth 2.0 标准流程 -- **Refresh Token Flow(刷新令牌流程)** - OAuth 2.0 标准流程 -- **JWT Bearer Grant** - RFC 7523 定义的流程(已弃用) - -#### 11.1.3 客户端认证方法 -- **client_secret_basic** - HTTP Basic Authentication(RFC 6749) -- **client_secret_post** - POST 表单认证(RFC 6749) -- **private_key_jwt** - JWT 断言认证(RFC 7523 Section 2.2) -- **none** - 无客户端认证(用于公共客户端) - -#### 11.1.4 令牌管理 -- **令牌交换** - 使用授权码/客户端凭证交换访问令牌(OAuth 标准) -- **令牌刷新** - 使用 refresh_token 刷新访问令牌(OAuth 标准) -- **令牌过期检测** - 基于 `expires_in` 字段(OAuth 标准) - -#### 11.1.5 错误处理 -- **insufficient_scope 错误** - OAuth 2.0 标准错误类型(RFC 6750) -- **权限升级流程** - 基于 insufficient_scope 错误重新授权(OAuth 标准实践) - -#### 11.1.6 元数据发现(通用 OAuth) -- **授权服务器元数据发现** - RFC 8414 定义的标准发现机制 -- **OIDC 发现端点回退** - OpenID Connect Discovery 1.0 兼容性 - -### 11.2 MCP 授权必需内容 - -这些是 MCP 授权规范特定的要求,虽然可能基于 OAuth 标准,但 MCP 有特定的实现要求: - -#### 11.2.1 受保护资源元数据(RFC 9728) -- **RFC 9728 PRM 实现** - 虽然是 OAuth 标准,但 MCP **MUST** 实现 -- **从 WWW-Authenticate 头提取 `resource_metadata` 参数** - MCP 特定的发现机制 -- **路径感知的 well-known URI** - MCP 特定的回退机制: - - `/.well-known/oauth-protected-resource{path}`(路径感知) - - `/.well-known/oauth-protected-resource`(根路径回退) - -#### 11.2.2 发现机制要求 -- **MUST 支持两种发现机制**: - 1. WWW-Authenticate 头中的 `resource_metadata` 参数 - 2. Well-known URI 回退机制 -- **MUST 优先使用 WWW-Authenticate 头** - MCP 特定的优先级规则 -- **MUST 能够解析 WWW-Authenticate 头并响应 401 错误** - MCP 特定的集成方式 - -#### 11.2.3 Scope 选择策略 -- **优先级顺序**(MCP 特定): - 1. `WWW-Authenticate` 头中的 `scope` 参数(最高优先级) - 2. PRM 的 `scopes_supported` 字段 - 3. OASM 的 `scopes_supported` 字段 - 4. 省略 scope 参数(最低优先级) -- **从 WWW-Authenticate 头提取 scope** - MCP 特定的行为 - -#### 11.2.4 自动触发机制 -- **收到 401 Unauthorized 时自动触发授权流程** - MCP 特定的集成方式 -- **自动重试原始请求** - MCP 特定的行为 - -#### 11.2.5 资源参数支持 -- **条件判断** - MCP 特定的逻辑: - - 协议版本 >= `2025-06-18`,或 - - 存在受保护资源元数据(PRM) -- **在授权请求中包含 `resource` 参数** - 基于 RFC 8707,但 MCP 有特定的触发条件 - -#### 11.2.6 URL-based Client ID (CIMD) -- **OAuth Client ID Metadata Documents** - 虽然是 OAuth 标准扩展,但 MCP **SHOULD** 支持 -- **CIMD 条件检查** - MCP 特定的实现逻辑: - - 检查 `client_id_metadata_document_supported == true` - - 验证 `client_metadata_url` 必须是 HTTPS URL 且路径不为根路径 - -#### 11.2.7 路径感知的元数据发现 -- **授权服务器元数据发现的路径感知机制** - MCP 特定的回退策略: - 1. `/.well-known/oauth-authorization-server{path}`(路径插入) - 2. `/.well-known/openid-configuration{path}`(路径插入) - 3. `{path}/.well-known/openid-configuration`(路径后置) - 4. 根路径回退 - -#### 11.2.8 服务器端要求 -- **MCP 服务器 MUST 实现 RFC 9728 PRM** - MCP 特定的强制要求 -- **MCP 服务器 MUST 在 PRM 中包含 `authorization_servers` 字段** - MCP 特定的数据结构要求 -- **MCP 服务器 SHOULD 在 WWW-Authenticate 头中包含 `scope` 参数** - MCP 特定的建议 - -### 11.3 混合特性(OAuth 标准 + MCP 特定要求) - -某些内容既是 OAuth 标准,但 MCP 有特定的强制要求: - -#### 11.3.1 PKCE -- **OAuth 标准**:RFC 7636 定义的可选扩展 -- **MCP 要求**:**MUST** 使用 PKCE(OAuth 2.1 要求,MCP 遵循 OAuth 2.1) -- **MCP 特定**:**MUST** 使用 S256 方法,**MUST** 验证授权服务器支持 - -#### 11.3.2 动态客户端注册(DCR) -- **OAuth 标准**:RFC 7591 定义的可选功能 -- **MCP 要求**:**MAY** 支持(可选,但 MCP 客户端应该支持) - -#### 11.3.3 授权服务器元数据发现 -- **OAuth 标准**:RFC 8414 定义的标准机制 -- **MCP 要求**:**MUST** 支持,且必须支持 OIDC 发现端点作为回退 - -### 11.4 总结 - -#### OAuth 协议自身特性(约 60%) -- 核心协议和标准(RFC 6749, 7636, 8414, 8707, 7523, 7591) -- 授权流程类型(授权码、客户端凭证、刷新令牌) -- 客户端认证方法(basic, post, jwt, none) -- 令牌管理和错误处理 -- 通用的元数据发现机制 - -#### MCP 授权必需内容(约 40%) -- RFC 9728 PRM 的强制实现 -- WWW-Authenticate 头解析和 `resource_metadata` 参数提取 -- 路径感知的 well-known URI 发现机制 -- MCP 特定的 Scope 选择策略 -- 自动触发授权流程(401 响应) -- 资源参数的 MCP 特定触发条件 -- CIMD 的 MCP 特定实现逻辑 - -#### 关键区别 -1. **OAuth 标准**:定义了"可以做什么"和"如何做" -2. **MCP 要求**:定义了"必须做什么"和"特定的实现方式" -3. **MCP 扩展**:在 OAuth 标准基础上添加了特定的发现机制、优先级规则和集成方式 - -## 十二、实现新鉴权协议所需功能 - -基于上述分析,如果要实现 MCP 支持一套新的鉴权协议(与 OAuth 不同),需要提供或实现以下功能。这些功能分为三类:**MCP 必需的基础设施**、**协议特定的实现**和**可选的增强功能**。 - -### 12.1 MCP 必需的基础设施(必须实现) - -这些是 MCP 授权规范要求的基础设施,所有协议都需要支持: - -#### 12.1.1 受保护资源元数据(PRM)支持 -- **实现 RFC 9728 PRM 端点** - - 提供 `/.well-known/oauth-protected-resource` 端点(或路径感知版本) - - 返回包含协议信息的 PRM JSON 文档 - - 支持从 `WWW-Authenticate` 头中的 `resource_metadata` 参数指向 PRM URL - -- **扩展 PRM 数据结构**(使用 RFC 9728 扩展机制) - - 在 PRM 中添加 `mcp_auth_protocols` 字段,声明支持的协议列表 - - 每个协议元数据包含: - - `protocol_id`: 协议标识符(如 "api_key", "mutual_tls") - - `protocol_version`: 协议版本 - - `metadata_url`: 协议特定的元数据发现 URL(可选) - - `endpoints`: 协议端点映射(如 token_endpoint, authorize_endpoint) - - `capabilities`: 协议能力列表 - - `client_auth_methods`: 客户端认证方法(如果适用) - - `additional_params`: 协议特定的额外参数 - -#### 12.1.2 WWW-Authenticate 头支持 -- **服务器端:构建扩展的 WWW-Authenticate 头** - - 在 401 响应中包含协议声明: - ``` - WWW-Authenticate: Bearer error="invalid_token", - resource_metadata="https://...", - auth_protocols="oauth2 api_key mutual_tls", - default_protocol="oauth2", - protocol_preferences="oauth2:1,api_key:2" - ``` - - 支持协议特定的认证方案(如 `ApiKey`, `MutualTLS`) - -- **客户端:解析 WWW-Authenticate 头** - - 提取 `resource_metadata` 参数 - - 解析 `auth_protocols` 字段(如果存在) - - 识别 `default_protocol` 和协议优先级 - - 支持非 Bearer 的认证方案 - -#### 12.1.3 协议发现机制 -- **协议发现顺序**(客户端) - - 优先级 1:PRM 的 `mcp_auth_protocols`(若已取得 PRM) - - 优先级 2:路径相对统一发现 `/.well-known/authorization_servers{path}` - - 优先级 3:根路径统一发现 `/.well-known/authorization_servers` - - 优先级 4:若上述均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退 -- **统一的能力发现端点** - - 实现 `/.well-known/authorization_servers`(及路径相对版本 `/.well-known/authorization_servers{path}`) - - 返回服务器支持的所有授权协议列表和能力信息 -- **鉴权发现日志**:发现过程输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志;客户端设置 `LOG_LEVEL=DEBUG` 可查看。 - -- **协议特定的元数据发现** - - 每个协议可以提供自己的元数据发现端点 - - 客户端根据协议 ID 调用相应的发现逻辑 - - 支持路径感知的 well-known URI(如 `/.well-known/{protocol}-metadata{path}`) - -#### 12.1.4 自动触发机制 -- **客户端:401 响应自动触发授权** - - 检测 401 Unauthorized 响应 - - 自动解析 WWW-Authenticate 头 - - 自动发现协议并启动授权流程 - - 授权成功后自动重试原始请求 - -- **客户端:403 响应权限升级** - - 检测 403 Forbidden 和 insufficient_scope 错误 - - 提取新的权限要求 - - 重新执行授权流程获取新凭证 - -### 12.2 协议特定的实现(每个协议必须实现) - -这些是每个新协议需要实现的核心功能: - -#### 12.2.1 协议抽象接口实现 -```python -class AuthProtocol(Protocol): - """授权协议抽象接口""" - - protocol_id: str # 协议标识符 - protocol_version: str # 协议版本 - - async def discover_metadata( - self, - metadata_url: str | None, - prm: ProtectedResourceMetadata | None = None - ) -> AuthProtocolMetadata | None: - """发现协议特定的元数据""" - pass - - async def authenticate( - self, - context: AuthContext - ) -> AuthCredentials: - """执行协议特定的认证流程""" - pass - - def prepare_request( - self, - request: httpx.Request, - credentials: AuthCredentials - ) -> None: - """为请求添加协议特定的认证信息""" - pass - - async def register_client( - self, - context: AuthContext - ) -> ClientRegistrationResult | None: - """协议特定的客户端注册(如果适用)""" - pass - - def validate_credentials( - self, - credentials: AuthCredentials - ) -> bool: - """验证凭证是否有效(客户端)""" - pass -``` - -#### 12.2.2 元数据发现 -- **实现协议特定的元数据发现逻辑** - - 从协议元数据端点获取配置信息 - - 解析协议特定的能力声明 - - 提取协议端点(如 token_endpoint, authorize_endpoint) - - 缓存元数据以提高性能 - -#### 12.2.3 认证流程执行 -- **实现协议特定的认证步骤** - - OAuth 2.0: 授权码流程 + PKCE + 令牌交换 - - API Key: 直接使用配置的 API Key(可能涉及密钥交换) - - Mutual TLS: 客户端证书验证(在 TLS 握手时) - - 自定义协议: 实现特定的认证步骤序列 - -- **支持协议特定的交互方式** - - 用户交互(如 OAuth 授权码流程) - - 自动认证(如客户端凭证、API Key) - - 混合方式(如需要用户确认的 API Key 激活) - -#### 12.2.4 凭证管理 -- **定义协议特定的凭证数据结构** - ```python - class APIKeyCredentials(AuthCredentials): - protocol_id: str = "api_key" - api_key: str - key_id: str | None = None - expires_at: int | None = None - ``` - -- **实现凭证存储和检索** - - 凭证序列化/反序列化 - - 凭证过期检查 - - 凭证刷新机制(如果协议支持) - -#### 12.2.5 请求认证信息准备 -- **实现协议特定的认证头/参数添加** - - OAuth: `Authorization: Bearer ` - - API Key: `X-API-Key: ` 或 `Authorization: ApiKey ` - - Mutual TLS: 客户端证书(在 TLS 握手时,HTTP 层无需额外头) - - 自定义: 协议特定的头、查询参数或请求体参数 - -#### 12.2.6 服务器端凭证验证 -- **实现协议特定的验证逻辑** - - OAuth: JWT 验证、令牌内省、签名验证 - - API Key: 密钥验证、过期检查、权限检查 - - Mutual TLS: 证书链验证、CN/OU 验证、证书撤销检查 - - 自定义: 协议特定的验证步骤 - -- **实现验证器接口** - ```python - class CredentialVerifier(Protocol): - async def verify( - self, - request: Request - ) -> AuthInfo | None: - """验证请求中的凭证""" - pass - ``` - -### 12.3 可选的增强功能 - -这些功能可以提升协议实现的完整性和用户体验: - -#### 12.3.1 客户端注册机制 -- **如果协议需要客户端注册** - - 实现协议特定的注册流程 - - 支持动态注册(如 OAuth DCR) - - 支持静态配置(如预分配的 API Key) - - 支持 URL-based Client ID(如 OAuth CIMD) - -#### 12.3.2 权限/Scope 模型 -- **如果协议有权限概念** - - 定义协议特定的权限模型 - - 实现权限选择策略(类似 OAuth 的 scope 选择) - - 支持权限升级机制 - - 实现权限验证逻辑 - -#### 12.3.3 凭证刷新机制 -- **如果协议支持凭证刷新** - - 实现自动刷新逻辑 - - 在凭证过期前自动刷新 - - 刷新失败时的降级处理 - -#### 12.3.4 错误处理 -- **实现协议特定的错误处理** - - 定义协议错误代码 - - 实现错误响应构建(服务器端) - - 实现错误解析和处理(客户端) - - 支持协议切换(如果当前协议失败,尝试其他协议) - -#### 12.3.5 协议能力声明 -- **声明协议支持的能力** - - 凭证刷新 - - 权限升级 - - 客户端注册 - - 元数据发现 - - 其他协议特定的能力 - -### 12.4 实现检查清单 - -实现新协议时,可以使用以下检查清单确保完整性: - -#### 数据模型层 -- [ ] 定义协议元数据模型(`AuthProtocolMetadata`) -- [ ] 定义协议凭证模型(继承 `AuthCredentials`) -- [ ] 扩展 PRM 数据结构支持协议声明 -- [ ] 定义协议特定的错误响应模型(如果适用) - -#### 客户端实现 -- [ ] 实现 `AuthProtocol` 接口 -- [ ] 实现元数据发现逻辑 -- [ ] 实现认证流程执行 -- [ ] 实现请求认证信息准备 -- [ ] 实现凭证验证(客户端) -- [ ] 实现客户端注册(如果适用) -- [ ] 实现权限选择(如果适用) -- [ ] 实现错误处理 -- [ ] 在协议注册表中注册协议 - -#### 服务器实现 -- [ ] 实现 PRM 端点,包含协议声明 -- [ ] 实现协议特定的元数据发现端点(如果适用) -- [ ] 实现统一的能力发现端点(`/.well-known/authorization_servers`) -- [ ] 实现 `CredentialVerifier` 接口 -- [ ] 实现凭证验证逻辑 -- [ ] 实现 WWW-Authenticate 头构建(包含协议声明) -- [ ] 实现错误响应构建 -- [ ] 实现权限验证(如果适用) -- [ ] 在认证中间件中注册协议验证器 - -#### 集成和测试 -- [ ] 实现协议注册机制 -- [ ] 实现协议发现和选择逻辑 -- [ ] 实现协议切换机制(如果第一个协议失败) -- [ ] 编写单元测试 -- [ ] 编写集成测试 -- [ ] 编写文档和使用示例 - -### 12.5 与 OAuth 的区别 - -实现新协议时,需要注意以下与 OAuth 的区别: - -#### 不需要实现的功能(OAuth 特定) -- ❌ 授权码流程(OAuth 特定) -- ❌ PKCE(OAuth 特定) -- ❌ 令牌交换(OAuth 特定) -- ❌ Refresh Token(OAuth 特定) -- ❌ Scope 模型(OAuth 特定,除非新协议也有类似概念) -- ❌ OAuth 客户端认证方法(client_secret_basic 等) - -#### 必须实现的功能(MCP 通用) -- ✅ PRM 支持和协议声明 -- ✅ WWW-Authenticate 头解析/构建 -- ✅ 协议发现机制 -- ✅ 自动触发授权流程(401 响应) -- ✅ 凭证管理和验证 -- ✅ 请求认证信息准备 - -#### 可选实现的功能(协议特定) -- ⚠️ 客户端注册(取决于协议需求) -- ⚠️ 权限模型(取决于协议需求) -- ⚠️ 凭证刷新(取决于协议需求) -- ⚠️ 元数据发现(取决于协议复杂度) - -### 12.6 参考实现 - -可以参考以下现有实现来理解如何实现新协议: - -- **OAuth 2.0 实现**: [`src/mcp/client/auth/oauth2.py`](src/mcp/client/auth/oauth2.py) -- **客户端凭证扩展**: [`src/mcp/client/auth/extensions/client_credentials.py`](src/mcp/client/auth/extensions/client_credentials.py) -- **多协议设计文档**: [`src/mcp/client/auth/multi-protocol-design.md`](src/mcp/client/auth/multi-protocol-design.md) -- **扩展改造点分析**: [`mcp-auth-extension-analysis.md`](../mcp-auth-extension-analysis.md) - -### 12.7 总结 - -实现 MCP 支持新鉴权协议需要: - -1. **MCP 必需的基础设施**(约 40%):PRM 支持、WWW-Authenticate 头、协议发现、自动触发机制 -2. **协议特定的实现**(约 50%):协议接口实现、认证流程、凭证管理、验证逻辑 -3. **可选的增强功能**(约 10%):客户端注册、权限模型、凭证刷新、错误处理 - -关键是要区分: -- **MCP 通用要求**:所有协议都必须支持 -- **协议特定实现**:每个协议需要实现自己的逻辑 -- **OAuth 特定功能**:不需要在新协议中实现 - -## 十三、现有代码改造点清单 - -基于章节11和12的分析,特别是必须实现的功能清单,以下是扩展支持新鉴权协议需要对现有代码进行的改造点。 - -### 13.1 数据模型层改造 - -#### 13.1.1 扩展 ProtectedResourceMetadata(PRM) - -**文件**: `src/mcp/shared/auth.py` - -**当前状态**: 仅支持 OAuth 2.0 的 `authorization_servers` 字段 - -**改造内容**: -1. **新增协议元数据模型** - ```python - class AuthProtocolMetadata(BaseModel): - """单个授权协议的元数据(MCP 扩展)""" - protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") - protocol_version: str - metadata_url: AnyHttpUrl | None = None - endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) - capabilities: list[str] = Field(default_factory=list) - client_auth_methods: list[str] | None = None - grant_types: list[str] | None = None # OAuth 特定 - scopes_supported: list[str] | None = None # OAuth 特定 - additional_params: dict[str, Any] = Field(default_factory=dict) - ``` - -2. **扩展 ProtectedResourceMetadata** - ```python - class ProtectedResourceMetadata(BaseModel): - # 保持 RFC 9728 必需字段不变(向后兼容) - resource: AnyHttpUrl - authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) - - # ... 现有字段 ... - - # MCP 扩展字段(使用 mcp_ 前缀) - mcp_auth_protocols: list[AuthProtocolMetadata] | None = Field( - None, - description="MCP 扩展:支持的授权协议列表" - ) - mcp_default_auth_protocol: str | None = Field( - None, - description="MCP 扩展:默认推荐的授权协议 ID" - ) - mcp_auth_protocol_preferences: dict[str, int] | None = Field( - None, - description="MCP 扩展:协议优先级映射" - ) - ``` - -3. **向后兼容处理** - - 如果 `mcp_auth_protocols` 为空,自动从 `authorization_servers` 创建 OAuth 2.0 协议元数据 - - 标准 OAuth 客户端可以忽略 `mcp_*` 扩展字段 - -#### 13.1.2 新增通用凭证模型 - -**文件**: `src/mcp/shared/auth.py`(新增) - -**改造内容**: -1. **定义基础凭证接口** - ```python - class AuthCredentials(BaseModel): - """通用凭证基类""" - protocol_id: str - expires_at: int | None = None - - class OAuthCredentials(AuthCredentials): - """OAuth 凭证(现有 OAuthToken 的包装)""" - protocol_id: str = "oauth2" - access_token: str - token_type: Literal["Bearer"] = "Bearer" - refresh_token: str | None = None - scope: str | None = None - ``` - -2. **扩展 TokenStorage 协议** - ```python - class TokenStorage(Protocol): - async def get_tokens(self) -> AuthCredentials | None: ... - async def set_tokens(self, tokens: AuthCredentials) -> None: ... - # ... 现有方法 ... - ``` - -#### 13.1.3 新增协议抽象接口 - -**文件**: `src/mcp/client/auth/protocol.py`(新建) - -**改造内容**: -1. **定义协议抽象接口** - ```python - class AuthProtocol(Protocol): - protocol_id: str - protocol_version: str - - async def discover_metadata(...) -> AuthProtocolMetadata | None: ... - async def authenticate(...) -> AuthCredentials: ... - def prepare_request(...) -> None: ... - async def register_client(...) -> ClientRegistrationResult | None: ... - def validate_credentials(...) -> bool: ... - ``` - -2. **定义服务器端验证器接口** - ```python - class CredentialVerifier(Protocol): - async def verify(self, request: Request) -> AuthInfo | None: ... - ``` - -### 13.2 客户端代码改造 - -#### 13.2.1 WWW-Authenticate 头解析扩展 - -**文件**: `src/mcp/client/auth/utils.py` - -**当前函数**: `extract_field_from_www_auth()`, `extract_resource_metadata_from_www_auth()` - -**改造内容**: -1. **新增协议相关字段提取** - ```python - def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: - """提取 auth_protocols 字段""" - - def extract_default_protocol_from_www_auth(response: Response) -> str | None: - """提取 default_protocol 字段""" - - def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: - """提取 protocol_preferences 字段""" - ``` - -2. **扩展解析逻辑** - - 支持非 Bearer 的认证方案(如 `ApiKey`, `MutualTLS`) - - 解析协议声明和优先级 - -#### 13.2.2 协议发现机制 - -**文件**: `src/mcp/client/auth/utils.py` - -**当前函数**: `build_protected_resource_metadata_discovery_urls()`, `build_oauth_authorization_server_metadata_discovery_urls()` - -**改造内容**: -1. **新增统一能力发现端点支持**(发现顺序:PRM 优先,再路径相对/根路径统一发现,最后 OAuth 回退) - ```python - async def discover_authorization_servers( - resource_url: str, - http_client: httpx.AsyncClient, - prm: ProtectedResourceMetadata | None = None, - resource_path: str = "", - ) -> list[AuthProtocolMetadata]: - """协议发现:PRM.mcp_auth_protocols → 路径相对统一发现 → 根路径统一发现 → OAuth 回退""" - # 1. 若已有 PRM 且含 mcp_auth_protocols,直接使用 - # 2. 路径相对 /.well-known/authorization_servers{path},再根路径 - # 3. 若仍无协议列表且 PRM 含 authorization_servers,由调用方走 OAuth 回退 - ``` - -2. **新增协议特定的元数据发现** - ```python - async def discover_protocol_metadata( - protocol_id: str, - metadata_url: str | None, - prm: ProtectedResourceMetadata | None = None - ) -> AuthProtocolMetadata | None: - """协议特定的元数据发现""" - ``` - -3. **修改现有发现函数** - - `build_oauth_authorization_server_metadata_discovery_urls()` 改为协议特定的发现函数 - - 支持路径感知的协议元数据发现端点 - -#### 13.2.3 协议注册和选择机制 - -**文件**: `src/mcp/client/auth/registry.py`(新建) - -**改造内容**: -1. **实现协议注册表** - ```python - class AuthProtocolRegistry: - _protocols: dict[str, type[AuthProtocol]] = {} - - @classmethod - def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]): ... - - @classmethod - def get_protocol(cls, protocol_id: str) -> type[AuthProtocol] | None: ... - - @classmethod - def select_protocol( - cls, - available_protocols: list[str], - default_protocol: str | None = None, - preferences: dict[str, int] | None = None - ) -> str | None: ... - ``` - -2. **协议选择逻辑** - - 根据优先级、默认协议、客户端支持情况选择协议 - - 支持协议切换(如果第一个协议失败) - -#### 13.2.4 OAuthClientProvider 重构 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前类**: `OAuthClientProvider`, `OAuthContext` - -**改造内容**: -1. **抽象为多协议提供者** - ```python - class MultiProtocolAuthProvider(httpx.Auth): - """多协议认证提供者""" - def __init__( - self, - server_url: str, - protocols: list[AuthProtocol], - storage: TokenStorage, - ... - ): ... - - async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, None]: - # 1. 检查存储的凭证 - # 2. 如果无效,触发协议发现和选择 - # 3. 执行选中的协议认证流程 - # 4. 准备请求认证信息 - ``` - -2. **OAuthClientProvider 保持为 OAuth 逻辑的唯一实现(最大程度复用)** - - **不**将 OAuth 发现/注册/授权码/令牌交换逻辑迁出到 OAuth2Protocol - - 新增 `run_authentication(self, http_client, *, resource_metadata_url=None, scope_from_www_auth=None)`:使用 `http_client` 执行完整 OAuth 流程(PRM/ASM 发现、scope 选择、注册或 CIMD、授权码 + 令牌交换),与现有 401 分支行为一致 - - 现有 `async_auth_flow` 的 401 分支保持不变;多协议路径下由 OAuth2Protocol 调用 `run_authentication(http_client)` 复用同一套逻辑 - -3. **OAuth2Protocol 作为薄适配层** - - `OAuth2Protocol` 实现 `AuthProtocol`,**不**重复实现 OAuth 发现/注册/编排 - - `authenticate(context)`:从 `AuthContext` 与自身配置组装 OAuth 所需上下文,构造 `OAuthClientProvider`,写入已发现的 PRM、protocol_version、scope_from_www_auth 等,调用 `provider.run_authentication(context.http_client, ...)`,从 provider 的 current_tokens 转为 `OAuthCredentials` 并返回 - -4. **OAuthContext 扩展** - - 支持由多协议层传入已发现的 `protected_resource_metadata`、`protocol_version`、`scope_from_www_auth`(供 `run_authentication` 使用) - -#### 13.2.5 请求认证信息准备 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前方法**: `_add_auth_header()`(在 OAuthClientProvider 中) - -**改造内容**: -1. **抽象为协议方法** - - 每个协议实现自己的 `prepare_request()` 方法 - - OAuth: `Authorization: Bearer ` - - API Key: `X-API-Key: ` 或 `Authorization: ApiKey ` - - Mutual TLS: 在 TLS 握手时处理 - -2. **在 MultiProtocolAuthProvider 中调用** - ```python - def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials): - protocol = self.registry.get_protocol(credentials.protocol_id) - protocol.prepare_request(request, credentials) - ``` - -#### 13.2.6 凭证存储扩展 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前协议**: `TokenStorage` - -**改造内容**: -1. **扩展 TokenStorage 协议** - ```python - class TokenStorage(Protocol): - async def get_tokens(self) -> AuthCredentials | None: ... - async def set_tokens(self, tokens: AuthCredentials) -> None: ... - # 保持现有方法以支持向后兼容 - ``` - -2. **凭证序列化/反序列化** - - 支持多种凭证类型的序列化 - - 根据 `protocol_id` 反序列化为正确的类型 - -### 13.3 服务器端代码改造 - -#### 13.3.1 PRM 端点扩展 - -**文件**: `src/mcp/server/auth/routes.py` - -**当前函数**: `create_protected_resource_routes()` - -**改造内容**: -1. **扩展函数签名** - ```python - def create_protected_resource_routes( - resource_url: AnyHttpUrl, - authorization_servers: list[AnyHttpUrl], - scopes_supported: list[str] | None = None, - # 新增参数 - auth_protocols: list[AuthProtocolMetadata] | None = None, - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - ... - ) -> list[Route]: - ``` - -2. **构建扩展的 PRM** - ```python - metadata = ProtectedResourceMetadata( - resource=resource_url, - authorization_servers=authorization_servers, # 保持向后兼容 - scopes_supported=scopes_supported, - mcp_auth_protocols=auth_protocols, # 新增 - mcp_default_auth_protocol=default_protocol, # 新增 - mcp_auth_protocol_preferences=protocol_preferences, # 新增 - ... - ) - ``` - -#### 13.3.2 统一能力发现端点 - -**文件**: `src/mcp/server/auth/routes.py`(新增函数) - -**改造内容**: -1. **新增统一发现端点** - ```python - def create_authorization_servers_discovery_routes( - resource_url: AnyHttpUrl, - auth_protocols: list[AuthProtocolMetadata], - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - ) -> list[Route]: - """创建统一的能力发现端点 /.well-known/authorization_servers""" - ``` - -2. **实现端点处理器** - ```python - class AuthorizationServersDiscoveryHandler: - async def handle(self, request: Request) -> Response: - """返回服务器支持的所有授权协议列表""" - ``` - -#### 13.3.3 WWW-Authenticate 头构建扩展 - -**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` - -**当前方法**: `_send_auth_error()` - -**改造内容**: -1. **扩展错误响应构建** - ```python - async def _send_auth_error( - self, - send: Send, - status_code: int, - error: str, - description: str, - # 新增参数 - resource_metadata_url: AnyHttpUrl | None = None, - auth_protocols: list[str] | None = None, - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - ) -> None: - """构建扩展的 WWW-Authenticate 头""" - parts = [ - f'error="{error}"', - f'error_description="{description}"' - ] - - if resource_metadata_url: - parts.append(f'resource_metadata="{resource_metadata_url}"') - - if auth_protocols: - parts.append(f'auth_protocols="{" ".join(auth_protocols)}"') - - if default_protocol: - parts.append(f'default_protocol="{default_protocol}"') - - if protocol_preferences: - prefs_str = ",".join(f"{proto}:{priority}" - for proto, priority in protocol_preferences.items()) - parts.append(f'protocol_preferences="{prefs_str}"') - - www_auth = f"Bearer {', '.join(parts)}" - ``` - -2. **修改 RequireAuthMiddleware** - - 添加协议信息参数 - - 在 401/403 响应中包含协议声明 - -#### 13.3.4 认证后端重构 - -**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` - -**当前类**: `BearerAuthBackend` - -**改造内容**: -1. **新增多协议认证后端** - ```python - class MultiProtocolAuthBackend(AuthenticationBackend): - """多协议认证后端""" - def __init__( - self, - verifiers: dict[str, CredentialVerifier] - ): - self.verifiers = verifiers - - async def authenticate(self, conn: HTTPConnection): - """尝试所有支持的协议""" - for protocol_id, verifier in self.verifiers.items(): - result = await verifier.verify(conn) - if result: - return result - return None - ``` - -2. **BearerAuthBackend 适配** - - 将 `BearerAuthBackend` 改为 OAuth 特定的验证器 - - 在 `MultiProtocolAuthBackend` 中注册 - -3. **新增协议特定的验证器** - ```python - class APIKeyVerifier(CredentialVerifier): - async def verify(self, request: Request) -> AuthInfo | None: ... - - class MutualTLSVerifier(CredentialVerifier): - async def verify(self, request: Request) -> AuthInfo | None: ... - ``` - -#### 13.3.5 协议特定的元数据端点 - -**文件**: `src/mcp/server/auth/routes.py`(新增函数) - -**改造内容**: -1. **新增协议元数据端点创建函数** - ```python - def create_protocol_metadata_routes( - protocol_id: str, - metadata: AuthProtocolMetadata - ) -> list[Route]: - """创建协议特定的元数据发现端点""" - # 例如: /.well-known/api-key-metadata - ``` - -### 13.4 新增文件和模块 - -#### 13.4.1 协议抽象和接口 - -**新建文件**: `src/mcp/client/auth/protocol.py` -- `AuthProtocol` 协议接口 -- `AuthProtocolMetadata` 模型(或从 shared 导入) -- 协议注册表 - -**新建文件**: `src/mcp/client/auth/registry.py` -- `AuthProtocolRegistry` 类 -- 协议选择逻辑 - -#### 13.4.2 多协议提供者 - -**新建文件**: `src/mcp/client/auth/multi_protocol.py` -- `MultiProtocolAuthProvider` 类 -- 协议发现和选择逻辑 -- 凭证管理 - -#### 13.4.3 OAuth 协议实现 - -**新建文件**: `src/mcp/client/auth/protocols/oauth2.py` -- `OAuth2Protocol` 类(实现 `AuthProtocol`),**薄适配层** -- **不**迁移 OAuth 逻辑到此文件;`authenticate(context)` 内构造 `OAuthClientProvider`、填充上下文后调用 `provider.run_authentication(context.http_client)`,复用现有 `oauth2.py` 实现 - -#### 13.4.4 服务器端验证器 - -**新建文件**: `src/mcp/server/auth/verifiers.py` -- `CredentialVerifier` 协议接口 -- `OAuthTokenVerifier`(现有 TokenVerifier 的适配) -- `MultiProtocolAuthBackend` - -**新建文件**: `src/mcp/server/auth/handlers/discovery.py` -- `AuthorizationServersDiscoveryHandler` 类 - -### 13.5 改造优先级和依赖关系 - -#### 高优先级(必须首先实现) -1. **数据模型扩展**(13.1) - - `AuthProtocolMetadata` 模型 - - `ProtectedResourceMetadata` 扩展 - - `AuthCredentials` 基类 - -2. **协议抽象接口**(13.4.1) - - `AuthProtocol` 接口定义 - - `CredentialVerifier` 接口定义 - -3. **WWW-Authenticate 头扩展**(13.2.1, 13.3.3) - - 客户端解析扩展 - - 服务器端构建扩展 - -#### 中优先级(核心功能) -4. **协议发现机制**(13.2.2, 13.3.2) - - 统一能力发现端点 - - 协议特定的元数据发现 - -5. **协议注册和选择**(13.2.3) - - 协议注册表 - - 协议选择逻辑 - -6. **多协议提供者**(13.2.4, 13.4.2) - - `MultiProtocolAuthProvider` - - 协议切换机制 - -7. **认证后端重构**(13.3.4) - - `MultiProtocolAuthBackend` - - 协议特定的验证器 - -#### 低优先级(向后兼容和优化) -8. **OAuth 适配**(13.2.4) - - `OAuth2Protocol` 实现 - - `OAuthClientProvider` 向后兼容包装 - -9. **PRM 端点扩展**(13.3.1) - - 扩展 `create_protected_resource_routes()` - -10. **凭证存储扩展**(13.2.6) - - `TokenStorage` 协议扩展 - - 凭证序列化/反序列化 - -### 13.6 向后兼容策略 - -#### 数据模型兼容 -- 保持 `ProtectedResourceMetadata` 的 `authorization_servers` 字段为必需 -- `mcp_*` 扩展字段为可选 -- 如果 `mcp_auth_protocols` 为空,自动从 `authorization_servers` 创建 OAuth 协议元数据 - -#### API 兼容 -- `OAuthClientProvider` 保持现有 API 不变,并新增 `run_authentication(http_client, ...)` 供多协议路径调用 -- 多协议场景下 `OAuth2Protocol` 内部委托 `OAuthClientProvider.run_authentication`,不重复实现 OAuth 流程 -- 现有代码无需修改即可工作 - -#### 行为兼容 -- 默认行为:如果没有协议声明,使用 OAuth 2.0 -- 现有 OAuth 流程保持不变 -- 新协议作为可选功能添加 - -### 13.7 测试改造点 - -#### 单元测试 -- 数据模型序列化/反序列化测试 -- 协议发现逻辑测试 -- 协议选择逻辑测试 -- WWW-Authenticate 头解析/构建测试 - -#### 集成测试 -- 多协议认证流程测试 -- 协议切换测试 -- 向后兼容性测试 - -### 13.8 总结 - -改造点分为三个层次: - -1. **基础设施层**(必须首先实现): - - 数据模型扩展 - - 协议抽象接口 - - WWW-Authenticate 头扩展 - -2. **核心功能层**(实现多协议支持): - - 协议发现机制 - - 协议注册和选择 - - 多协议提供者和认证后端 - -3. **适配层**(向后兼容): - - OAuth 协议实现 - - 现有 API 的兼容包装 - - 凭证存储扩展 - -**关键原则**: -- 保持向后兼容 -- 渐进式改造 -- 协议抽象和统一接口 -- MCP 通用功能与协议特定实现分离 - -## 十四、OAuth 2.0 DPoP 支持改造点 - -DPoP (Demonstrating Proof-of-Possession, RFC 9449) 是 OAuth 2.0 的安全扩展,用于将访问令牌绑定到客户端的加密密钥。与多协议扩展不同,DPoP 是 OAuth 2.0 协议内部的增强功能,需要在现有 OAuth 实现基础上添加支持。 - -### 14.1 DPoP 概述 - -#### 14.1.1 DPoP 核心概念 -- **密钥对生成**:客户端生成公钥/私钥对用于签名 DPoP 证明 -- **DPoP 证明**:客户端为每个请求创建 DPoP JWT,包含: - - `htm`: HTTP 方法 - - `htu`: HTTP URI - - `jti`: JWT ID(防重放) - - `iat`: 签发时间 - - `ath`: 访问令牌哈希(如果存在) - - `cnf`: 确认信息(包含公钥的 JWK) -- **令牌绑定**:服务器可以将访问令牌绑定到 DPoP 公钥(通过 `cnf.jkt`) -- **验证**:资源服务器验证 DPoP 证明与令牌中的公钥匹配 - -#### 14.1.2 DPoP 与多协议设计的关系 -- DPoP 是 **OAuth 2.0 协议的扩展**,不是独立协议 -- 在 `OAuth2Protocol` 实现中添加 DPoP 支持 -- 不影响多协议架构,只是 OAuth 2.0 实现的一个可选增强 - -### 14.2 数据模型改造 - -#### 14.2.1 扩展 OAuthMetadata - -**文件**: `src/mcp/shared/auth.py` - -**当前状态**: `OAuthMetadata` 类已存在,但缺少 DPoP 相关字段 - -**改造内容**: -1. **添加 DPoP 元数据字段** - ```python - class OAuthMetadata(BaseModel): - # ... 现有字段 ... - - # DPoP 支持字段(RFC 9449) - dpop_signing_alg_values_supported: list[str] | None = Field( - None, - description="支持的 DPoP 签名算法列表(如 ['ES256', 'RS256'])" - ) - ``` - -2. **ProtectedResourceMetadata 已有字段** - - `dpop_signing_alg_values_supported` ✅ 已存在 - - `dpop_bound_access_tokens_required` ✅ 已存在 - -#### 14.2.2 新增 DPoP 密钥对模型 - -**文件**: `src/mcp/shared/auth.py`(新增) - -**改造内容**: -1. **定义 DPoP 密钥对模型** - ```python - class DPoPKeyPair(BaseModel): - """DPoP 密钥对""" - private_key_pem: str # PEM 格式的私钥 - public_key_jwk: dict[str, Any] # JWK 格式的公钥 - key_id: str | None = None # 可选的密钥 ID - - @classmethod - def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": - """生成新的 DPoP 密钥对""" - # 使用 cryptography 库生成密钥对 - ``` - -2. **扩展 OAuthToken 模型** - ```python - class OAuthToken(BaseModel): - # ... 现有字段 ... - - # DPoP 相关字段 - dpop_key_id: str | None = Field( - None, - description="DPoP 密钥 ID(如果令牌绑定到 DPoP)" - ) - cnf: dict[str, Any] | None = Field( - None, - description="确认信息(包含 jkt 如果令牌绑定到 DPoP)" - ) - ``` - -#### 14.2.3 扩展 TokenStorage 协议 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前协议**: `TokenStorage` - -**改造内容**: -1. **添加 DPoP 密钥对存储** - ```python - class TokenStorage(Protocol): - # ... 现有方法 ... - - async def get_dpop_key_pair(self) -> DPoPKeyPair | None: - """获取存储的 DPoP 密钥对""" - ... - - async def set_dpop_key_pair(self, key_pair: DPoPKeyPair) -> None: - """存储 DPoP 密钥对""" - ... - ``` - -### 14.3 客户端代码改造 - -#### 14.3.1 DPoP 证明生成 - -**文件**: `src/mcp/client/auth/dpop.py`(新建) - -**改造内容**: -1. **实现 DPoP 证明生成器** - ```python - class DPoPProofGenerator: - """DPoP 证明生成器""" - - def __init__(self, key_pair: DPoPKeyPair): - self.key_pair = key_pair - - def generate_proof( - self, - method: str, - uri: str, - access_token: str | None = None, - nonce: str | None = None - ) -> str: - """生成 DPoP 证明 JWT""" - # 1. 构建 JWT claims - claims = { - "htm": method, - "htu": uri, - "iat": int(time.time()), - "jti": secrets.token_urlsafe(32), - "cnf": {"jwk": self.key_pair.public_key_jwk} - } - - # 2. 如果存在访问令牌,添加 ath - if access_token: - claims["ath"] = base64.urlsafe_b64encode( - hashlib.sha256(access_token.encode()).digest() - ).decode().rstrip("=") - - # 3. 如果存在 nonce,添加 nonce - if nonce: - claims["nonce"] = nonce - - # 4. 使用私钥签名 JWT - # 5. 返回 JWT 字符串 - ``` - -2. **实现密钥对生成** - ```python - def generate_dpop_key_pair(algorithm: str = "ES256") -> DPoPKeyPair: - """生成 DPoP 密钥对""" - # 使用 cryptography 库生成密钥对 - # 转换为 PEM 和 JWK 格式 - ``` - -#### 14.3.2 OAuthContext 扩展 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前类**: `OAuthContext` - -**改造内容**: -1. **添加 DPoP 支持字段** - ```python - @dataclass - class OAuthContext: - # ... 现有字段 ... - - # DPoP 支持 - dpop_key_pair: DPoPKeyPair | None = None - dpop_enabled: bool = False - dpop_proof_generator: DPoPProofGenerator | None = None - ``` - -2. **添加 DPoP 初始化方法** - ```python - async def initialize_dpop(self) -> None: - """初始化 DPoP 支持""" - # 1. 检查服务器是否支持 DPoP(从 OAuthMetadata 或 PRM) - # 2. 如果支持,生成或加载密钥对 - # 3. 创建 DPoP 证明生成器 - ``` - -#### 14.3.3 请求准备扩展(添加 DPoP 头) - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前方法**: `_add_auth_header()` - -**改造内容**: -1. **扩展请求准备方法** - ```python - def _add_auth_header(self, request: httpx.Request) -> None: - """添加授权头和 DPoP 头(如果启用)""" - if self.context.current_tokens and self.context.current_tokens.access_token: - request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" - - # 如果启用 DPoP,添加 DPoP 证明 - if self.context.dpop_enabled and self.context.dpop_proof_generator: - dpop_proof = self.context.dpop_proof_generator.generate_proof( - method=request.method, - uri=str(request.url), - access_token=self.context.current_tokens.access_token - ) - request.headers["DPoP"] = dpop_proof - ``` - -2. **令牌请求中的 DPoP** - ```python - async def _exchange_token_authorization_code(...) -> httpx.Request: - """构建令牌交换请求(包含 DPoP 证明)""" - # ... 现有逻辑 ... - - # 如果启用 DPoP,在令牌请求中包含 DPoP 证明 - if self.context.dpop_enabled and self.context.dpop_proof_generator: - dpop_proof = self.context.dpop_proof_generator.generate_proof( - method="POST", - uri=token_url - ) - headers["DPoP"] = dpop_proof - - return httpx.Request("POST", token_url, data=token_data, headers=headers) - ``` - -#### 14.3.4 元数据发现和 DPoP 检测 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前方法**: `_handle_oauth_metadata_response()`, `_handle_protected_resource_response()` - -**改造内容**: -1. **检测服务器 DPoP 支持** - ```python - async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None: - content = await response.aread() - metadata = OAuthMetadata.model_validate_json(content) - self.context.oauth_metadata = metadata - - # 检查服务器是否支持 DPoP - if metadata.dpop_signing_alg_values_supported: - # 服务器支持 DPoP,初始化 DPoP - await self.context.initialize_dpop() - ``` - -2. **从 PRM 检测 DPoP 要求** - ```python - async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: - # ... 现有逻辑 ... - - if metadata.dpop_bound_access_tokens_required: - # 资源服务器要求 DPoP-bound tokens - if not self.context.dpop_enabled: - await self.context.initialize_dpop() - ``` - -#### 14.3.5 令牌刷新中的 DPoP - -**文件**: `src/mcp/client/auth/oauth2.py` - -**当前方法**: `_refresh_token()` - -**改造内容**: -1. **刷新令牌时包含 DPoP 证明** - ```python - async def _refresh_token(self) -> httpx.Request: - """构建令牌刷新请求(包含 DPoP 证明)""" - # ... 现有逻辑 ... - - # 如果启用 DPoP,在刷新请求中包含 DPoP 证明 - if self.context.dpop_enabled and self.context.dpop_proof_generator: - dpop_proof = self.context.dpop_proof_generator.generate_proof( - method="POST", - uri=token_url, - access_token=self.context.current_tokens.access_token if self.context.current_tokens else None - ) - headers["DPoP"] = dpop_proof - - return httpx.Request("POST", token_url, data=refresh_data, headers=headers) - ``` - -### 14.4 服务器端代码改造 - -#### 14.4.1 DPoP 证明验证器 - -**文件**: `src/mcp/server/auth/dpop.py`(新建) - -**改造内容**: -1. **实现 DPoP 证明验证器** - ```python - class DPoPProofVerifier: - """DPoP 证明验证器""" - - def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): - self.allowed_algorithms = allowed_algorithms - self.jti_cache: dict[str, int] = {} # 防重放缓存 - - async def verify_proof( - self, - dpop_proof: str, - method: str, - uri: str, - access_token: str | None = None - ) -> dict[str, Any]: - """验证 DPoP 证明""" - # 1. 解析 JWT - # 2. 验证签名(使用公钥) - # 3. 验证 htm 和 htu 匹配请求 - # 4. 验证 jti 唯一性(防重放) - # 5. 验证 iat 在时间窗口内 - # 6. 如果存在 access_token,验证 ath 匹配 - # 7. 提取公钥(用于令牌绑定) - ``` - -2. **实现重放保护** - ```python - def _check_jti(self, jti: str, iat: int) -> bool: - """检查 jti 是否已使用(防重放)""" - # 清理过期的 jti - current_time = int(time.time()) - self.jti_cache = { - k: v for k, v in self.jti_cache.items() - if current_time - v < 300 # 5 分钟窗口 - } - - # 检查 jti 是否已存在 - if jti in self.jti_cache: - return False - - # 记录 jti - self.jti_cache[jti] = iat - return True - ``` - -#### 14.4.2 令牌颁发中的 DPoP 绑定 - -**文件**: `src/mcp/server/auth/handlers/token.py` - -**当前类**: `TokenHandler` - -**改造内容**: -1. **在令牌响应中包含 DPoP 绑定** - ```python - class TokenHandler: - def __init__(self, ..., dpop_verifier: DPoPProofVerifier | None = None): - # ... 现有参数 ... - self.dpop_verifier = dpop_verifier - - async def handle(self, request: Request) -> Response: - """处理令牌请求(支持 DPoP)""" - # 1. 提取 DPoP 证明(如果存在) - dpop_proof = request.headers.get("DPoP") - - # 2. 如果存在 DPoP 证明,验证它 - dpop_jwk_thumbprint: str | None = None - if dpop_proof: - dpop_info = await self.dpop_verifier.verify_proof( - dpop_proof=dpop_proof, - method=request.method, - uri=str(request.url) - ) - # 计算 JWK Thumbprint (jkt) - dpop_jwk_thumbprint = self._calculate_jwk_thumbprint( - dpop_info["cnf"]["jwk"] - ) - - # 3. 颁发访问令牌(如果 DPoP 存在,绑定到 jkt) - access_token = await self.provider.issue_access_token( - client_id=client_id, - scopes=scopes, - dpop_jkt=dpop_jwk_thumbprint # 绑定到 DPoP 公钥 - ) - - # 4. 在令牌响应中包含 cnf(如果绑定) - token_response = { - "access_token": access_token.token, - "token_type": "Bearer", - "expires_in": access_token.expires_in, - } - - if dpop_jwk_thumbprint: - token_response["cnf"] = { - "jkt": dpop_jwk_thumbprint - } - ``` - -2. **计算 JWK Thumbprint** - ```python - def _calculate_jwk_thumbprint(self, jwk: dict[str, Any]) -> str: - """计算 JWK Thumbprint (RFC 7638)""" - # 1. 规范化 JWK(按字母顺序排序,移除无关字段) - # 2. 计算 SHA-256 哈希 - # 3. Base64 URL 编码 - ``` - -#### 14.4.3 资源服务器验证扩展 - -**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` - -**当前类**: `BearerAuthBackend` - -**改造内容**: -1. **扩展认证后端支持 DPoP 验证** - ```python - class BearerAuthBackend(AuthenticationBackend): - def __init__( - self, - token_verifier: TokenVerifier, - dpop_verifier: DPoPProofVerifier | None = None - ): - self.token_verifier = token_verifier - self.dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection): - # ... 现有 Bearer token 验证 ... - - # 如果令牌包含 cnf.jkt,验证 DPoP 证明 - if auth_info.cnf and auth_info.cnf.get("jkt"): - dpop_proof = conn.headers.get("DPoP") - if not dpop_proof: - return None # DPoP-bound token 必须包含 DPoP 证明 - - # 验证 DPoP 证明 - dpop_info = await self.dpop_verifier.verify_proof( - dpop_proof=dpop_proof, - method=conn.scope["method"], - uri=str(conn.url), - access_token=token - ) - - # 验证 DPoP 公钥与令牌中的 jkt 匹配 - dpop_jkt = self._calculate_jwk_thumbprint(dpop_info["cnf"]["jwk"]) - if dpop_jkt != auth_info.cnf["jkt"]: - return None # 公钥不匹配 - ``` - -2. **扩展 AccessToken 模型** - ```python - class AccessToken(BaseModel): - # ... 现有字段 ... - cnf: dict[str, Any] | None = None # 确认信息(包含 jkt) - ``` - -#### 14.4.4 元数据端点扩展 - -**文件**: `src/mcp/server/auth/routes.py` - -**当前函数**: `build_metadata()` - -**改造内容**: -1. **在 OAuth 元数据中声明 DPoP 支持** - ```python - def build_metadata( - issuer_url: AnyHttpUrl, - ..., - dpop_signing_alg_values_supported: list[str] | None = None, - ) -> OAuthMetadata: - """构建 OAuth 元数据(包含 DPoP 支持)""" - return OAuthMetadata( - # ... 现有字段 ... - dpop_signing_alg_values_supported=dpop_signing_alg_values_supported or ["ES256"], - ) - ``` - -2. **在 PRM 中声明 DPoP 要求** - ```python - def create_protected_resource_routes( - ..., - dpop_bound_access_tokens_required: bool = False, - dpop_signing_alg_values_supported: list[str] | None = None, - ) -> list[Route]: - """创建 PRM 路由(包含 DPoP 配置)""" - metadata = ProtectedResourceMetadata( - # ... 现有字段 ... - dpop_bound_access_tokens_required=dpop_bound_access_tokens_required, - dpop_signing_alg_values_supported=dpop_signing_alg_values_supported or ["ES256"], - ) - ``` - -### 14.5 改造优先级和依赖关系 - -#### 高优先级(核心功能) -1. **DPoP 证明生成和验证**(14.3.1, 14.4.1) - - DPoP 密钥对生成 - - DPoP 证明 JWT 生成 - - DPoP 证明验证器 - -2. **请求准备扩展**(14.3.3) - - 在请求中添加 DPoP 头 - - 令牌请求中的 DPoP 支持 - -#### 中优先级(完整支持) -3. **令牌绑定**(14.4.2) - - 令牌颁发时绑定到 DPoP 公钥 - - 在令牌响应中包含 `cnf.jkt` - -4. **资源服务器验证**(14.4.3) - - 验证 DPoP-bound tokens - - 验证 DPoP 证明与令牌匹配 - -5. **元数据声明**(14.4.4) - - OAuth 元数据中的 DPoP 支持声明 - - PRM 中的 DPoP 要求声明 - -#### 低优先级(优化和增强) -6. **自动检测和启用**(14.3.4) - - 从元数据自动检测 DPoP 支持 - - 自动初始化 DPoP - -7. **密钥对持久化**(14.2.3) - - DPoP 密钥对存储 - - 密钥对重用 - -### 14.6 与多协议设计的关系 - -#### DPoP 是 OAuth 2.0 的扩展 -- **不是独立协议**:DPoP 是 OAuth 2.0 协议内部的增强 -- **在 OAuth2Protocol 中实现**:所有 DPoP 功能都在 `OAuth2Protocol` 类中 -- **不影响协议抽象**:多协议架构不需要修改 - -#### 实现位置 -- **客户端**:在 `OAuth2Protocol` 的 `prepare_request()` 方法中添加 DPoP 头 -- **服务器端**:在 `OAuthTokenVerifier` 中添加 DPoP 验证逻辑 - -#### 向后兼容 -- **可选功能**:DPoP 是可选的,不影响现有 OAuth 流程 -- **渐进式启用**:客户端和服务器可以逐步启用 DPoP -- **降级支持**:如果服务器不支持 DPoP,客户端回退到标准 Bearer token - -### 14.7 总结 - -DPoP 支持的改造点: - -1. **数据模型**: - - 扩展 `OAuthMetadata` 添加 DPoP 字段 - - 新增 `DPoPKeyPair` 模型 - - 扩展 `TokenStorage` 协议 - -2. **客户端**: - - DPoP 证明生成器 - - 请求准备时添加 DPoP 头 - - 自动检测和启用 DPoP - -3. **服务器端**: - - DPoP 证明验证器 - - 令牌绑定到 DPoP 公钥 - - 资源服务器验证 DPoP-bound tokens - -4. **关键特性**: - - 防重放攻击(jti 验证) - - 时间窗口验证(iat 检查) - - 令牌绑定(cnf.jkt) - - 自动检测和降级 - -**与多协议设计的关系**: -- DPoP 是 OAuth 2.0 的扩展,不是新协议 -- 在 `OAuth2Protocol` 实现中添加支持 -- 不影响多协议架构和协议抽象 - -## 十四、DPoP 抽象设计(通用证明持有机制) - -DPoP (Demonstrating Proof-of-Possession, RFC 9449) 虽然最初为 OAuth 2.0 设计,但其核心概念(证明持有加密密钥)是通用的,可以抽象为独立的组件,供多个授权协议使用。 - -### 14.1 DPoP 抽象设计理念 - -#### 14.1.1 为什么需要抽象 -- **通用性**:证明持有机制不仅限于 OAuth 2.0,其他协议(如 API Key、Mutual TLS)也可以使用 -- **代码复用**:避免在每个协议中重复实现 DPoP 逻辑 -- **一致性**:统一的 DPoP 实现确保所有协议使用相同的安全标准 -- **可扩展性**:未来新协议可以轻松集成 DPoP 支持 - -#### 14.1.2 抽象层次 -``` -┌─────────────────────────────────────────┐ -│ 协议层 (OAuth2Protocol, etc.) │ -│ 使用 DPoP 组件增强安全性 │ -├─────────────────────────────────────────┤ -│ DPoP 抽象层 (通用组件) │ -│ - DPoPProofGenerator (客户端) │ -│ - DPoPProofVerifier (服务器端) │ -│ - DPoPKeyPair (密钥管理) │ -├─────────────────────────────────────────┤ -│ 基础层 (JWT, 加密, HTTP) │ -└─────────────────────────────────────────┘ -``` - -### 14.2 通用 DPoP 接口设计 - -#### 14.2.1 客户端 DPoP 接口 - -**文件**: `src/mcp/client/auth/dpop.py`(新建) - -**改造内容**: -1. **定义通用 DPoP 证明生成器接口** - ```python - class DPoPProofGenerator(Protocol): - """DPoP 证明生成器接口(协议无关)""" - - def generate_proof( - self, - method: str, - uri: str, - credential: str | None = None, # 通用凭证(OAuth 中是 access_token) - nonce: str | None = None - ) -> str: - """生成 DPoP 证明 JWT""" - ... - - def get_public_key_jwk(self) -> dict[str, Any]: - """获取公钥 JWK(用于令牌绑定)""" - ... - ``` - -2. **实现通用 DPoP 证明生成器** - ```python - class DPoPProofGeneratorImpl: - """DPoP 证明生成器实现""" - - def __init__(self, key_pair: DPoPKeyPair): - self.key_pair = key_pair - - def generate_proof( - self, - method: str, - uri: str, - credential: str | None = None, - nonce: str | None = None - ) -> str: - """生成 DPoP 证明 JWT(协议无关)""" - claims = { - "htm": method, - "htu": uri, - "iat": int(time.time()), - "jti": secrets.token_urlsafe(32), - "cnf": {"jwk": self.key_pair.public_key_jwk} - } - - # 如果存在凭证,添加凭证哈希(协议特定字段名) - if credential: - claims["ath"] = self._hash_credential(credential) - - if nonce: - claims["nonce"] = nonce - - # 使用私钥签名 JWT - return self._sign_jwt(claims) - - def _hash_credential(self, credential: str) -> str: - """哈希凭证(通用方法)""" - return base64.urlsafe_b64encode( - hashlib.sha256(credential.encode()).digest() - ).decode().rstrip("=") - ``` - -3. **定义密钥对模型(协议无关)** - ```python - class DPoPKeyPair(BaseModel): - """DPoP 密钥对(协议无关)""" - private_key_pem: str - public_key_jwk: dict[str, Any] - key_id: str | None = None - algorithm: str = "ES256" # 默认算法 - - @classmethod - def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": - """生成新的 DPoP 密钥对""" - # 使用 cryptography 库生成密钥对 - # 转换为 PEM 和 JWK 格式 - ``` - -#### 14.2.2 服务器端 DPoP 接口 - -**文件**: `src/mcp/server/auth/dpop.py`(新建) - -**改造内容**: -1. **定义通用 DPoP 验证器接口** - ```python - class DPoPProofVerifier(Protocol): - """DPoP 证明验证器接口(协议无关)""" - - async def verify_proof( - self, - dpop_proof: str, - method: str, - uri: str, - credential: str | None = None # 通用凭证 - ) -> DPoPProofInfo: - """验证 DPoP 证明""" - ... - ``` - -2. **实现通用 DPoP 验证器** - ```python - class DPoPProofInfo(BaseModel): - """DPoP 证明信息""" - public_key_jwk: dict[str, Any] - jwk_thumbprint: str # JWK Thumbprint (jkt) - jti: str - iat: int - - class DPoPProofVerifierImpl: - """DPoP 证明验证器实现""" - - def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): - self.allowed_algorithms = allowed_algorithms - self.jti_cache: dict[str, int] = {} # 防重放缓存 - - async def verify_proof( - self, - dpop_proof: str, - method: str, - uri: str, - credential: str | None = None - ) -> DPoPProofInfo: - """验证 DPoP 证明(协议无关)""" - # 1. 解析 JWT - claims = self._parse_jwt(dpop_proof) - - # 2. 验证签名 - self._verify_signature(dpop_proof, claims) - - # 3. 验证 htm 和 htu 匹配请求 - if claims["htm"] != method or claims["htu"] != uri: - raise DPoPValidationError("HTTP method or URI mismatch") - - # 4. 验证 jti 唯一性(防重放) - if not self._check_jti(claims["jti"], claims["iat"]): - raise DPoPValidationError("Replay attack detected") - - # 5. 验证 iat 在时间窗口内 - self._verify_iat(claims["iat"]) - - # 6. 如果存在凭证,验证 ath 匹配 - if credential and "ath" in claims: - expected_ath = self._hash_credential(credential) - if claims["ath"] != expected_ath: - raise DPoPValidationError("Credential hash mismatch") - - # 7. 提取公钥信息 - public_key_jwk = claims["cnf"]["jwk"] - jwk_thumbprint = self._calculate_jwk_thumbprint(public_key_jwk) - - return DPoPProofInfo( - public_key_jwk=public_key_jwk, - jwk_thumbprint=jwk_thumbprint, - jti=claims["jti"], - iat=claims["iat"] - ) - ``` - -#### 14.2.3 DPoP 存储接口(协议无关) - -**文件**: `src/mcp/client/auth/dpop.py` - -**改造内容**: -1. **定义 DPoP 存储接口** - ```python - class DPoPStorage(Protocol): - """DPoP 密钥对存储接口(协议无关)""" - - async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: - """获取指定协议的 DPoP 密钥对""" - ... - - async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: - """存储指定协议的 DPoP 密钥对""" - ... - ``` - -2. **实现内存存储(示例)** - ```python - class InMemoryDPoPStorage: - """内存 DPoP 存储实现""" - def __init__(self): - self._key_pairs: dict[str, DPoPKeyPair] = {} - - async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: - return self._key_pairs.get(protocol_id) - - async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: - self._key_pairs[protocol_id] = key_pair - ``` - -### 14.3 协议集成接口 - -#### 14.3.1 协议 DPoP 支持接口 - -**文件**: `src/mcp/client/auth/protocol.py` - -**改造内容**: -1. **扩展 AuthProtocol 接口** - ```python - class AuthProtocol(Protocol): - # ... 现有方法 ... - - # DPoP 支持(可选) - def supports_dpop(self) -> bool: - """协议是否支持 DPoP""" - ... - - def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: - """获取 DPoP 证明生成器(如果支持)""" - ... - - def prepare_request_with_dpop( - self, - request: httpx.Request, - credentials: AuthCredentials, - dpop_generator: DPoPProofGenerator | None = None - ) -> None: - """准备请求(包含 DPoP 头,如果启用)""" - ... - ``` - -#### 14.3.2 OAuth 2.0 协议集成 - -**文件**: `src/mcp/client/auth/protocols/oauth2.py` - -**改造内容**: -1. **OAuth2Protocol 实现 DPoP 支持** - ```python - class OAuth2Protocol(AuthProtocol): - def __init__( - self, - ..., - dpop_enabled: bool = False, - dpop_storage: DPoPStorage | None = None - ): - # ... 现有初始化 ... - self.dpop_enabled = dpop_enabled - self.dpop_storage = dpop_storage - self.dpop_generator: DPoPProofGenerator | None = None - - def supports_dpop(self) -> bool: - """OAuth 2.0 支持 DPoP""" - return self.dpop_enabled - - async def initialize_dpop(self) -> None: - """初始化 DPoP 支持""" - if not self.dpop_enabled: - return - - # 检查服务器是否支持 DPoP - if not self._server_supports_dpop(): - self.dpop_enabled = False - return - - # 加载或生成密钥对 - key_pair = await self.dpop_storage.get_key_pair("oauth2") - if not key_pair: - key_pair = DPoPKeyPair.generate() - await self.dpop_storage.set_key_pair("oauth2", key_pair) - - self.dpop_generator = DPoPProofGeneratorImpl(key_pair) - - def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: - return self.dpop_generator - - def prepare_request_with_dpop( - self, - request: httpx.Request, - credentials: AuthCredentials, - dpop_generator: DPoPProofGenerator | None = None - ) -> None: - """准备 OAuth 请求(包含 DPoP)""" - # 添加 Bearer token - if isinstance(credentials, OAuthCredentials): - request.headers["Authorization"] = f"Bearer {credentials.access_token}" - - # 如果启用 DPoP,添加 DPoP 证明 - generator = dpop_generator or self.dpop_generator - if generator: - dpop_proof = generator.generate_proof( - method=request.method, - uri=str(request.url), - credential=credentials.access_token # OAuth 特定:使用 access_token - ) - request.headers["DPoP"] = dpop_proof - ``` - -#### 14.3.3 其他协议集成示例 - -**文件**: `src/mcp/client/auth/protocols/api_key.py`(示例) - -**改造内容**: -1. **API Key 协议也可以使用 DPoP** - ```python - class APIKeyProtocol(AuthProtocol): - def prepare_request_with_dpop( - self, - request: httpx.Request, - credentials: AuthCredentials, - dpop_generator: DPoPProofGenerator | None = None - ) -> None: - """准备 API Key 请求(可选 DPoP 增强)""" - if isinstance(credentials, APIKeyCredentials): - request.headers["X-API-Key"] = credentials.api_key - - # 可选:使用 DPoP 增强安全性 - if self.dpop_enabled and dpop_generator: - dpop_proof = dpop_generator.generate_proof( - method=request.method, - uri=str(request.url), - credential=credentials.api_key # API Key 特定:使用 api_key - ) - request.headers["DPoP"] = dpop_proof - ``` - -### 14.4 多协议提供者集成 - -#### 14.4.1 MultiProtocolAuthProvider 中的 DPoP 支持 - -**文件**: `src/mcp/client/auth/multi_protocol.py` - -**改造内容**: -1. **在提供者中管理 DPoP** - ```python - class MultiProtocolAuthProvider(httpx.Auth): - def __init__( - self, - ..., - dpop_storage: DPoPStorage | None = None, - dpop_enabled: bool = False - ): - # ... 现有初始化 ... - self.dpop_storage = dpop_storage or InMemoryDPoPStorage() - self.dpop_enabled = dpop_enabled - - async def _prepare_request( - self, - request: httpx.Request, - protocol: AuthProtocol, - credentials: AuthCredentials - ) -> None: - """准备请求(包含 DPoP,如果协议支持)""" - # 获取协议的 DPoP 生成器 - dpop_generator = None - if self.dpop_enabled and protocol.supports_dpop(): - dpop_generator = protocol.get_dpop_proof_generator() - if not dpop_generator: - # 初始化 DPoP - await protocol.initialize_dpop() - dpop_generator = protocol.get_dpop_proof_generator() - - # 协议特定的请求准备(包含 DPoP) - protocol.prepare_request_with_dpop( - request=request, - credentials=credentials, - dpop_generator=dpop_generator - ) - ``` - -### 14.5 服务器端集成 - -#### 14.5.1 通用 DPoP 验证器集成 - -**文件**: `src/mcp/server/auth/verifiers.py` - -**改造内容**: -1. **在验证器中集成 DPoP** - ```python - class CredentialVerifier(Protocol): - """凭证验证器接口""" - - async def verify( - self, - request: Request, - dpop_verifier: DPoPProofVerifier | None = None - ) -> AuthInfo | None: - """验证凭证(可选 DPoP 验证)""" - ... - ``` - -2. **OAuth Token 验证器集成 DPoP** - ```python - class OAuthTokenVerifier(CredentialVerifier): - def __init__( - self, - token_verifier: TokenVerifier, - dpop_verifier: DPoPProofVerifier | None = None - ): - self.token_verifier = token_verifier - self.dpop_verifier = dpop_verifier - - async def verify( - self, - request: Request, - dpop_verifier: DPoPProofVerifier | None = None - ) -> AuthInfo | None: - """验证 OAuth token(包含 DPoP 验证)""" - # 1. 验证 Bearer token - token = self._extract_bearer_token(request) - auth_info = await self.token_verifier.verify_token(token) - - if not auth_info: - return None - - # 2. 如果令牌绑定到 DPoP,验证 DPoP 证明 - verifier = dpop_verifier or self.dpop_verifier - if auth_info.cnf and auth_info.cnf.get("jkt") and verifier: - dpop_proof = request.headers.get("DPoP") - if not dpop_proof: - return None # DPoP-bound token 必须包含 DPoP 证明 - - # 验证 DPoP 证明 - dpop_info = await verifier.verify_proof( - dpop_proof=dpop_proof, - method=request.method, - uri=str(request.url), - credential=token # OAuth 特定:使用 access_token - ) - - # 验证公钥匹配 - if dpop_info.jwk_thumbprint != auth_info.cnf["jkt"]: - return None - - return auth_info - ``` - -#### 14.5.2 多协议认证后端集成 - -**文件**: `src/mcp/server/auth/verifiers.py` - -**改造内容**: -1. **MultiProtocolAuthBackend 中的 DPoP 支持** - ```python - class MultiProtocolAuthBackend(AuthenticationBackend): - def __init__( - self, - verifiers: dict[str, CredentialVerifier], - dpop_verifier: DPoPProofVerifier | None = None - ): - self.verifiers = verifiers - self.dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection): - """尝试所有协议的验证(包含 DPoP)""" - for protocol_id, verifier in self.verifiers.items(): - result = await verifier.verify( - request=Request(conn.scope, conn.receive), - dpop_verifier=self.dpop_verifier - ) - if result: - return result - return None - ``` - -### 14.6 元数据扩展(协议无关) - -#### 14.6.1 协议元数据中的 DPoP 支持声明 - -**文件**: `src/mcp/shared/auth.py` - -**改造内容**: -1. **在 AuthProtocolMetadata 中添加 DPoP 支持** - ```python - class AuthProtocolMetadata(BaseModel): - # ... 现有字段 ... - - # DPoP 支持(协议无关) - dpop_signing_alg_values_supported: list[str] | None = Field( - None, - description="支持的 DPoP 签名算法(如果协议支持 DPoP)" - ) - dpop_bound_credentials_required: bool | None = Field( - None, - description="是否要求 DPoP-bound 凭证(协议特定术语)" - ) - ``` - -2. **OAuth 特定字段映射** - - OAuth 中:`dpop_bound_access_tokens_required` - - 通用术语:`dpop_bound_credentials_required` - - 在 OAuth 实现中映射这两个字段 - -### 14.7 改造点总结 - -#### 14.7.1 新增文件 - -1. **`src/mcp/client/auth/dpop.py`**(新建) - - `DPoPProofGenerator` 接口和实现 - - `DPoPKeyPair` 模型 - - `DPoPStorage` 接口 - - 密钥对生成和 JWK Thumbprint 计算 - -2. **`src/mcp/server/auth/dpop.py`**(新建) - - `DPoPProofVerifier` 接口和实现 - - `DPoPProofInfo` 模型 - - 重放保护逻辑 - -#### 14.7.2 修改文件 - -1. **`src/mcp/client/auth/protocol.py`** - - 扩展 `AuthProtocol` 接口添加 DPoP 支持方法 - -2. **`src/mcp/client/auth/protocols/oauth2.py`** - - 实现 DPoP 支持方法 - - 集成 DPoP 证明生成器 - -3. **`src/mcp/client/auth/multi_protocol.py`** - - 在提供者中管理 DPoP 存储和生成器 - -4. **`src/mcp/server/auth/verifiers.py`** - - 在验证器中集成 DPoP 验证 - -5. **`src/mcp/shared/auth.py`** - - 在 `AuthProtocolMetadata` 中添加 DPoP 字段 - -### 14.8 使用示例 - -#### 14.8.1 OAuth 2.0 使用 DPoP - -```python -# 客户端 -dpop_storage = InMemoryDPoPStorage() -oauth_protocol = OAuth2Protocol( - ..., - dpop_enabled=True, - dpop_storage=dpop_storage -) - -# 服务器端 -dpop_verifier = DPoPProofVerifierImpl(allowed_algorithms=["ES256"]) -oauth_verifier = OAuthTokenVerifier( - token_verifier=token_verifier, - dpop_verifier=dpop_verifier -) -``` - -#### 14.8.2 API Key 使用 DPoP(可选增强) - -```python -# 客户端 -api_key_protocol = APIKeyProtocol( - ..., - dpop_enabled=True, # 可选:使用 DPoP 增强 API Key 安全性 - dpop_storage=dpop_storage -) -``` - -### 14.9 优势总结 - -#### 14.9.1 抽象设计的优势 - -1. **代码复用**:DPoP 逻辑只需实现一次,所有协议共享 -2. **一致性**:所有协议使用相同的 DPoP 实现和安全标准 -3. **灵活性**:协议可以选择是否支持 DPoP -4. **可扩展性**:新协议可以轻松集成 DPoP 支持 -5. **测试简化**:DPoP 逻辑可以独立测试 - -#### 14.9.2 与多协议架构的集成 - -- **协议无关**:DPoP 组件不依赖特定协议 -- **可选增强**:协议可以选择性地使用 DPoP -- **统一接口**:所有协议使用相同的 DPoP 接口 -- **向后兼容**:不支持 DPoP 的协议仍然可以正常工作 - -### 14.10 总结 - -DPoP 抽象设计的关键点: - -1. **独立组件**:DPoP 作为独立的通用组件,不绑定到特定协议 -2. **统一接口**:所有协议使用相同的 DPoP 接口 -3. **可选集成**:协议可以选择是否支持 DPoP -4. **灵活使用**:可以在 OAuth、API Key 等协议中使用 -5. **向后兼容**:不影响不支持 DPoP 的协议 - -**实现层次**: -- **基础层**:DPoP 核心组件(生成器、验证器、密钥管理) -- **集成层**:协议特定的 DPoP 集成 -- **应用层**:多协议提供者中的 DPoP 管理 - -这种设计使得 DPoP 成为一个可复用的安全增强组件,可以在任何需要证明持有机制的授权协议中使用。 diff --git a/src/mcp/client/auth/multi-protocol-refactoring-plan.md b/src/mcp/client/auth/multi-protocol-refactoring-plan.md deleted file mode 100644 index a2d344dea..000000000 --- a/src/mcp/client/auth/multi-protocol-refactoring-plan.md +++ /dev/null @@ -1,1409 +0,0 @@ -# MCP 多协议授权支持改造计划 - -> 基于章节12.5(与OAuth的区别)和章节13(现有代码改造点清单),结合DPoP抽象设计,制定的完整改造计划 - -**相关文档**:`docs/authorization-multiprotocol.md`(多协议设计与用法)、`docs/dpop-nonce-implementation-plan.md`(DPoP nonce 实现方案)、`mcp/client/auth/multi-protocol-design.md`(顶层设计) - -## 一、改造目标 - -### 1.1 核心目标 -- 支持多个授权协议(OAuth 2.0、API Key、Mutual TLS等) -- 保持与现有OAuth实现的完全向后兼容 -- 提供统一的协议抽象接口 -- 支持DPoP作为可选的通用安全增强组件 - -### 1.2 设计原则 -1. **协议抽象**:统一的协议接口,所有授权协议实现相同的基础接口 -2. **向后兼容**:现有OAuth 2.0实现无需修改即可工作 -3. **协议发现**:服务器声明支持的协议,客户端自动发现和选择 -4. **灵活扩展**:开发者可以轻松添加新的授权协议 -5. **标准兼容**:基于现有HTTP和MCP规范,最小化扩展 - -## 二、功能分类(基于章节12.5) - -### 2.1 不需要实现的功能(OAuth特定) -以下功能是OAuth 2.0协议特有的,新协议不需要实现: - -- ❌ **授权码流程**(OAuth特定) -- ❌ **PKCE**(OAuth特定) -- ❌ **令牌交换**(OAuth特定) -- ❌ **Refresh Token**(OAuth特定) -- ❌ **Scope模型**(OAuth特定,除非新协议也有类似概念) -- ❌ **OAuth客户端认证方法**(client_secret_basic等) - -**说明**:Client Credentials 作为 OAuth 2.0 的 **grant type** 在现有 OAuth2 流程中实现(`OAuth2Protocol` + `fixed_client_info`),不单独新增协议;AS 需在 token 端点支持 `grant_type=client_credentials` 并在元数据中声明 `grant_types_supported`。 - -### 2.2 必须实现的功能(MCP通用) -以下功能是MCP授权规范要求的,所有协议都必须支持: - -- ✅ **PRM支持和协议声明** -- ✅ **WWW-Authenticate头解析/构建** -- ✅ **协议发现机制** -- ✅ **自动触发授权流程**(401响应) -- ✅ **凭证管理和验证** -- ✅ **请求认证信息准备** - -### 2.3 可选实现的功能(协议特定) -以下功能取决于协议的具体需求: - -- ⚠️ **客户端注册**(取决于协议需求) -- ⚠️ **权限模型**(取决于协议需求) -- ⚠️ **凭证刷新**(取决于协议需求) -- ⚠️ **元数据发现**(取决于协议复杂度) - -### 2.4 通用安全增强(DPoP) -DPoP作为独立的通用组件,协议可以选择性使用: - -- ⚠️ **DPoP支持**(可选,但建议支持以增强安全性) - -## 三、改造点详细清单 - -### 3.1 数据模型层改造 - -#### 3.1.1 扩展 ProtectedResourceMetadata(PRM) - -**文件**: `src/mcp/shared/auth.py` - -**优先级**: 🔴 高 - -**改造内容**: -1. **新增协议元数据模型** - ```python - class AuthProtocolMetadata(BaseModel): - """单个授权协议的元数据(MCP扩展)""" - protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") - protocol_version: str - metadata_url: AnyHttpUrl | None = None - endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) - capabilities: list[str] = Field(default_factory=list) - client_auth_methods: list[str] | None = None # OAuth特定 - grant_types: list[str] | None = None # OAuth特定 - scopes_supported: list[str] | None = None # OAuth特定 - # DPoP支持(协议无关) - dpop_signing_alg_values_supported: list[str] | None = None - dpop_bound_credentials_required: bool | None = None - additional_params: dict[str, Any] = Field(default_factory=dict) - ``` - -2. **扩展 ProtectedResourceMetadata** - ```python - class ProtectedResourceMetadata(BaseModel): - # 保持RFC 9728必需字段不变(向后兼容) - resource: AnyHttpUrl - authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) - - # ... 现有字段 ... - - # MCP扩展字段(使用mcp_前缀) - mcp_auth_protocols: list[AuthProtocolMetadata] | None = Field( - None, - description="MCP扩展:支持的授权协议列表" - ) - mcp_default_auth_protocol: str | None = Field( - None, - description="MCP扩展:默认推荐的授权协议ID" - ) - mcp_auth_protocol_preferences: dict[str, int] | None = Field( - None, - description="MCP扩展:协议优先级映射" - ) - ``` - -3. **向后兼容处理** - - 如果`mcp_auth_protocols`为空,自动从`authorization_servers`创建OAuth 2.0协议元数据 - - 标准OAuth客户端可以忽略`mcp_*`扩展字段 - -#### 3.1.2 新增通用凭证模型 - -**文件**: `src/mcp/shared/auth.py` - -**优先级**: 🔴 高 - -**改造内容**: -1. **定义基础凭证接口** - ```python - class AuthCredentials(BaseModel): - """通用凭证基类""" - protocol_id: str - expires_at: int | None = None - - class OAuthCredentials(AuthCredentials): - """OAuth凭证(现有OAuthToken的包装)""" - protocol_id: str = "oauth2" - access_token: str - token_type: Literal["Bearer"] = "Bearer" - refresh_token: str | None = None - scope: str | None = None - cnf: dict[str, Any] | None = None # DPoP绑定信息 - - class APIKeyCredentials(AuthCredentials): - """API Key凭证""" - protocol_id: str = "api_key" - api_key: str - key_id: str | None = None - ``` - -2. **扩展TokenStorage协议** - ```python - class TokenStorage(Protocol): - async def get_tokens(self) -> AuthCredentials | None: ... - async def set_tokens(self, tokens: AuthCredentials) -> None: ... - # 保持现有方法以支持向后兼容 - async def get_client_info(self) -> OAuthClientInformationFull | None: ... - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: ... - ``` - -#### 3.1.3 新增协议抽象接口 - -**文件**: `src/mcp/client/auth/protocol.py`(新建) - -**优先级**: 🔴 高 - -**改造内容**: -1. **定义基础协议抽象接口(必需方法)** - ```python - class AuthProtocol(Protocol): - """授权协议基础接口(所有协议必须实现)""" - protocol_id: str - protocol_version: str - - async def authenticate( - self, - context: AuthContext - ) -> AuthCredentials: - """执行协议特定的认证流程(必需)""" - ... - - def prepare_request( - self, - request: httpx.Request, - credentials: AuthCredentials - ) -> None: - """为请求添加协议特定的认证信息(必需)""" - ... - - def validate_credentials( - self, - credentials: AuthCredentials - ) -> bool: - """验证凭证是否有效(客户端,必需)""" - ... - - async def discover_metadata( - self, - metadata_url: str | None, - prm: ProtectedResourceMetadata | None = None - ) -> AuthProtocolMetadata | None: - """发现协议特定的元数据(可选,默认返回None)""" - return None - ``` - -2. **定义可选功能扩展接口** - ```python - class ClientRegisterableProtocol(AuthProtocol): - """支持客户端注册的协议扩展接口(可选)""" - async def register_client( - self, - context: AuthContext - ) -> ClientRegistrationResult | None: - """协议特定的客户端注册""" - ... - - class DPoPEnabledProtocol(AuthProtocol): - """支持DPoP的协议扩展接口(可选)""" - def supports_dpop(self) -> bool: - """协议是否支持DPoP""" - ... - - def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: - """获取DPoP证明生成器""" - ... - - async def initialize_dpop(self) -> None: - """初始化DPoP支持""" - ... - ``` - -2. **定义服务器端验证器接口** - ```python - class CredentialVerifier(Protocol): - """凭证验证器接口""" - async def verify( - self, - request: Request, - dpop_verifier: DPoPProofVerifier | None = None - ) -> AuthInfo | None: - """验证请求中的凭证(可选DPoP验证)""" - ... - ``` - -### 3.2 客户端代码改造 - -#### 3.2.1 WWW-Authenticate头解析扩展 - -**文件**: `src/mcp/client/auth/utils.py` - -**优先级**: 🔴 高 - -**改造内容**: -1. **新增协议相关字段提取** - ```python - def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: - """提取auth_protocols字段""" - return extract_field_from_www_auth(response, "auth_protocols") - - def extract_default_protocol_from_www_auth(response: Response) -> str | None: - """提取default_protocol字段""" - return extract_field_from_www_auth(response, "default_protocol") - - def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: - """提取protocol_preferences字段""" - prefs_str = extract_field_from_www_auth(response, "protocol_preferences") - if not prefs_str: - return None - # 解析格式: "oauth2:1,api_key:2" - preferences = {} - for item in prefs_str.split(","): - proto, priority = item.split(":") - preferences[proto] = int(priority) - return preferences - ``` - -2. **扩展解析逻辑** - - 支持多种认证方式:OAuth 使用标准 `Bearer`;API Key 使用 `X-API-Key` 或可选 `Authorization: Bearer `(标准 scheme,不解析非标准 `ApiKey`);Mutual TLS(mTLS,客户端证书)在 TLS/HTTPS 连接层(握手时)处理;与 IANA 的 "Mutual" scheme(RFC 8120,密码双向认证)无关 - - 解析协议声明和优先级 - - 支持多个认证方案(如果服务器支持) - -#### 3.2.2 协议发现机制 - -**文件**: `src/mcp/client/auth/utils.py` - -**优先级**: 🟡 中 - -**改造内容**: -1. **新增统一能力发现端点支持**(发现顺序取舍见 **十一、11.4**) - ```python - async def discover_authorization_servers( - resource_url: str, - http_client: httpx.AsyncClient, - prm: ProtectedResourceMetadata | None = None, - resource_path: str = "", - ) -> list[AuthProtocolMetadata]: - """统一的授权服务器/协议发现流程(PRM 优先,再统一发现,最后 OAuth 回退)""" - # 1. 若已有 PRM 且含 mcp_auth_protocols,直接使用 - if prm and getattr(prm, "mcp_auth_protocols", None): - return _protocol_metadata_list_from_prm(prm) - # 2. 路径相对统一发现:/.well-known/authorization_servers{path} - urls = build_authorization_servers_discovery_urls(resource_url, resource_path) - for url in urls: - # 尝试请求,200 且含 protocols 则解析并返回 - ... - # 3. 若仍无协议列表且 PRM 含 authorization_servers,走 OAuth 回退(由调用方处理) - return [] - ``` - -2. **新增协议特定的元数据发现** - ```python - async def discover_protocol_metadata( - protocol_id: str, - metadata_url: str | None, - prm: ProtectedResourceMetadata | None = None - ) -> AuthProtocolMetadata | None: - """协议特定的元数据发现""" - # 根据协议ID调用相应的发现逻辑 - # OAuth: 使用RFC 8414发现 - # API Key: 使用自定义发现端点 - # 其他协议: 协议特定的发现逻辑 - ``` - -3. **修改现有发现函数** - - `build_oauth_authorization_server_metadata_discovery_urls()`改为协议特定的发现函数 - - 支持路径感知的协议元数据发现端点 - -#### 3.2.3 协议注册和选择机制 - -**文件**: `src/mcp/client/auth/registry.py`(新建) - -**优先级**: 🟡 中 - -**改造内容**: -1. **实现协议注册表** - ```python - class AuthProtocolRegistry: - """协议注册表""" - _protocols: dict[str, type[AuthProtocol]] = {} - - @classmethod - def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]): - """注册协议实现""" - cls._protocols[protocol_id] = protocol_class - - @classmethod - def get_protocol_class(cls, protocol_id: str) -> type[AuthProtocol] | None: - """获取协议实现类""" - return cls._protocols.get(protocol_id) - - @classmethod - def select_protocol( - cls, - available_protocols: list[str], - default_protocol: str | None = None, - preferences: dict[str, int] | None = None - ) -> str | None: - """选择协议""" - # 1. 过滤客户端支持的协议 - supported = [p for p in available_protocols if p in cls._protocols] - if not supported: - return None - - # 2. 如果有默认协议且支持,优先选择 - if default_protocol and default_protocol in supported: - return default_protocol - - # 3. 如果有优先级,按优先级排序 - if preferences: - supported.sort(key=lambda p: preferences.get(p, 999)) - - # 4. 返回第一个支持的协议 - return supported[0] if supported else None - ``` - -2. **协议选择逻辑** - - 根据优先级、默认协议、客户端支持情况选择协议 - - 支持协议切换(如果第一个协议失败) - -#### 3.2.4 OAuthClientProvider重构 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**优先级**: 🟡 中 - -**改造内容**: -1. **抽象为多协议提供者** - ```python - class MultiProtocolAuthProvider(httpx.Auth): - """多协议认证提供者""" - requires_response_body = True - - def __init__( - self, - server_url: str, - storage: TokenStorage, - protocols: list[AuthProtocol] | None = None, - dpop_storage: DPoPStorage | None = None, - dpop_enabled: bool = False, - timeout: float = 300.0, - ): - self.server_url = server_url - self.storage = storage - self.protocols = protocols or [] - self.dpop_storage = dpop_storage or InMemoryDPoPStorage() - self.dpop_enabled = dpop_enabled - self.timeout = timeout - self.registry = AuthProtocolRegistry() - self._initialized = False - self._current_protocol: AuthProtocol | None = None - - async def async_auth_flow( - self, - request: httpx.Request - ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """HTTPX认证流程入口""" - async with self._lock: - if not self._initialized: - await self._initialize() - - # 1. 检查存储的凭证 - credentials = await self.storage.get_tokens() - - # 2. 如果凭证无效,触发协议发现和选择 - if not credentials or not self._is_credentials_valid(credentials): - await self._discover_and_authenticate(request) - credentials = await self.storage.get_tokens() - - # 3. 准备请求认证信息 - if credentials: - await self._prepare_request(request, credentials) - - # 4. 发送请求 - response = yield request - - # 5. 处理401/403响应(OAuth 分支通过 oauth_401_flow_generator 驱动,取舍见十一、11.1) - if response.status_code == 401: - await self._handle_401_response(response, request) - elif response.status_code == 403: - await self._handle_403_response(response, request) - ``` - -2. **OAuthClientProvider 保持为 OAuth 逻辑唯一实现(最大程度复用)** - - **不**将 OAuth 逻辑迁出到 OAuth2Protocol;新增 `run_authentication(http_client, ...)` 供多协议路径调用 - - 保持现有 API 不变(向后兼容);OAuth2Protocol 为薄适配层,内部委托 `OAuthClientProvider.run_authentication` - - 取舍原因见 **十一、设计取舍与方案说明 11.1** - -3. **协议上下文扩展** - ```python - @dataclass - class AuthContext: - """通用认证上下文""" - server_url: str - storage: TokenStorage - protocol_id: str - protocol_metadata: AuthProtocolMetadata | None = None - current_credentials: AuthCredentials | None = None - # DPoP支持(可选,阶段4实现) - dpop_storage: DPoPStorage | None = None - dpop_enabled: bool = False - ``` - -#### 3.2.5 请求认证信息准备 - -**文件**: `src/mcp/client/auth/multi_protocol.py` - -**优先级**: 🔴 高 - -**改造内容**: -1. **在MultiProtocolAuthProvider中实现** - ```python - async def _prepare_request( - self, - request: httpx.Request, - credentials: AuthCredentials - ) -> None: - """准备请求(包含DPoP,如果协议支持)""" - # 获取协议实例 - protocol = self._get_protocol(credentials.protocol_id) - if not protocol: - raise AuthError(f"Protocol {credentials.protocol_id} not found") - - # 协议特定的请求准备(必需) - protocol.prepare_request(request, credentials) - - # DPoP支持(可选,仅在协议实现DPoPEnabledProtocol时) - if self.dpop_enabled and isinstance(protocol, DPoPEnabledProtocol): - if protocol.supports_dpop(): - dpop_generator = protocol.get_dpop_proof_generator() - if not dpop_generator: - await protocol.initialize_dpop() - dpop_generator = protocol.get_dpop_proof_generator() - - if dpop_generator: - # 获取凭证字符串(协议特定) - credential_str = self._get_credential_string(credentials) - dpop_proof = dpop_generator.generate_proof( - method=request.method, - uri=str(request.url), - credential=credential_str - ) - request.headers["DPoP"] = dpop_proof - ``` - - **注意**:DPoP集成是可选功能,仅在阶段4实现。在阶段2-3中,可以暂时忽略DPoP相关代码。 - -2. **协议特定的实现示例** - - OAuth: `Authorization: Bearer ` - - API Key: 优先 `X-API-Key: `,可选 `Authorization: Bearer `(标准 scheme;不解析非标准 `ApiKey`;服务端通过验证器顺序区分,不在 token 内加前缀) - - Mutual TLS(mTLS): 在 TLS 握手时处理(非 HTTP Authorization scheme) - -#### 3.2.6 凭证存储扩展 - -**文件**: `src/mcp/client/auth/oauth2.py` - -**优先级**: 🟢 低 - -**改造内容**: -1. **凭证序列化/反序列化** - ```python - def serialize_credentials(credentials: AuthCredentials) -> dict[str, Any]: - """序列化凭证""" - data = credentials.model_dump() - data["_type"] = credentials.__class__.__name__ - return data - - def deserialize_credentials(data: dict[str, Any]) -> AuthCredentials: - """反序列化凭证""" - type_name = data.pop("_type") - if type_name == "OAuthCredentials": - return OAuthCredentials(**data) - elif type_name == "APIKeyCredentials": - return APIKeyCredentials(**data) - # ... 其他类型 - ``` - -#### 3.2.7 API Key 认证方案约定(方案 A) - -**取舍**:采用 X-API-Key + 可选 Bearer,不解析非标准 `ApiKey` scheme。取舍原因见 **十一、11.2**。 - -**约定**(与前述 3.2.5 协议特定的实现示例一致): -- **标准兼容**:不解析非标准 `Authorization: ApiKey `(`ApiKey` 非 IANA 注册 scheme);API Key 使用标准 `Bearer` 或专用 header。 -- **服务端**:优先从 `X-API-Key` header 读取;可选从 `Authorization: Bearer ` 读取并在 `valid_keys` 中查找;由 `MultiProtocolAuthBackend` 的验证器顺序区分(OAuthTokenVerifier 先尝试 Bearer → TokenVerifier,APIKeyVerifier 再尝试 X-API-Key / Bearer-in-valid_keys)。 -- **客户端**:推荐使用 `X-API-Key: `;若需统一走 Bearer,可发送 `Authorization: Bearer `(服务端需配置允许 Bearer 作为 API Key)。 -- **不在 token 内加前缀**:不要求 `apikey:xxx` 等格式,符合 RFC 6750 Bearer token 为 opaque string 的语义;区分由验证逻辑与 header 完成。 - -### 3.3 服务器端代码改造 - -#### 3.3.1 PRM端点扩展 - -**文件**: `src/mcp/server/auth/routes.py` - -**优先级**: 🟡 中 - -**改造内容**: -1. **扩展函数签名** - ```python - def create_protected_resource_routes( - resource_url: AnyHttpUrl, - authorization_servers: list[AnyHttpUrl], - scopes_supported: list[str] | None = None, - # 新增参数 - auth_protocols: list[AuthProtocolMetadata] | None = None, - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - ) -> list[Route]: - """创建PRM路由(支持多协议)""" - metadata = ProtectedResourceMetadata( - resource=resource_url, - authorization_servers=authorization_servers, # 保持向后兼容 - scopes_supported=scopes_supported, - mcp_auth_protocols=auth_protocols, # 新增 - mcp_default_auth_protocol=default_protocol, # 新增 - mcp_auth_protocol_preferences=protocol_preferences, # 新增 - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - handler = ProtectedResourceMetadataHandler(metadata) - # ... 路由创建逻辑 - ``` - -#### 3.3.2 统一能力发现端点 - -**文件**: `src/mcp/server/auth/routes.py`(新增函数) - -**优先级**: 🟡 中 - -**改造内容**: -1. **新增统一发现端点** - ```python - def create_authorization_servers_discovery_routes( - resource_url: AnyHttpUrl, - auth_protocols: list[AuthProtocolMetadata], - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - ) -> list[Route]: - """创建统一的能力发现端点/.well-known/authorization_servers""" - handler = AuthorizationServersDiscoveryHandler( - auth_protocols=auth_protocols, - default_protocol=default_protocol, - protocol_preferences=protocol_preferences, - ) - - return [ - Route( - "/.well-known/authorization_servers", - endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), - methods=["GET", "OPTIONS"], - ) - ] - ``` - -2. **实现端点处理器** - ```python - class AuthorizationServersDiscoveryHandler: - async def handle(self, request: Request) -> Response: - """返回服务器支持的所有授权协议列表""" - response_data = { - "protocols": [ - protocol.model_dump() - for protocol in self.auth_protocols - ] - } - if self.default_protocol: - response_data["default_protocol"] = self.default_protocol - if self.protocol_preferences: - response_data["protocol_preferences"] = self.protocol_preferences - - return JSONResponse(response_data) - ``` - -#### 3.3.3 WWW-Authenticate头构建扩展 - -**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` - -**优先级**: 🔴 高 - -**改造内容**: -1. **扩展错误响应构建** - ```python - async def _send_auth_error( - self, - send: Send, - status_code: int, - error: str, - description: str, - # 新增参数 - resource_metadata_url: AnyHttpUrl | None = None, - auth_protocols: list[str] | None = None, - default_protocol: str | None = None, - protocol_preferences: dict[str, int] | None = None, - ) -> None: - """构建扩展的WWW-Authenticate头""" - parts = [ - f'error="{error}"', - f'error_description="{description}"' - ] - - if resource_metadata_url: - parts.append(f'resource_metadata="{resource_metadata_url}"') - - if auth_protocols: - protocols_str = " ".join(auth_protocols) - parts.append(f'auth_protocols="{protocols_str}"') - - if default_protocol: - parts.append(f'default_protocol="{default_protocol}"') - - if protocol_preferences: - prefs_str = ",".join( - f"{proto}:{priority}" - for proto, priority in protocol_preferences.items() - ) - parts.append(f'protocol_preferences="{prefs_str}"') - - # 确定认证方案(根据支持的协议) - scheme = self._determine_auth_scheme(auth_protocols) - www_auth = f"{scheme} {', '.join(parts)}" - - # 发送响应 - await send({ - "type": "http.response.start", - "status": status_code, - "headers": [ - [b"www-authenticate", www_auth.encode()], - [b"content-type", b"application/json"], - ], - }) - ``` - -2. **修改RequireAuthMiddleware** - - 添加协议信息参数 - - 在401/403响应中包含协议声明 - -#### 3.3.4 认证后端重构 - -**文件**: `src/mcp/server/auth/middleware/bearer_auth.py` - -**优先级**: 🟡 中 - -**改造内容**: -1. **新增多协议认证后端** - ```python - class MultiProtocolAuthBackend(AuthenticationBackend): - """多协议认证后端""" - def __init__( - self, - verifiers: dict[str, CredentialVerifier], - dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 - ): - self.verifiers = verifiers - self.dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection): - """尝试所有支持的协议""" - request = Request(conn.scope, conn.receive) - - for protocol_id, verifier in self.verifiers.items(): - result = await verifier.verify( - request=request, - dpop_verifier=self.dpop_verifier # 可选,阶段4实现 - ) - if result: - return result - return None - ``` - - **注意**:DPoP验证器参数是可选的,在阶段2-3中可以为None。 - -2. **BearerAuthBackend适配** - - 将`BearerAuthBackend`改为OAuth特定的验证器 - - 在`MultiProtocolAuthBackend`中注册 - -3. **新增协议特定的验证器** - ```python - class OAuthTokenVerifier(CredentialVerifier): - """OAuth Token验证器""" - def __init__( - self, - token_verifier: TokenVerifier, - dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 - ): - self.token_verifier = token_verifier - self.dpop_verifier = dpop_verifier - - async def verify( - self, - request: Request, - dpop_verifier: DPoPProofVerifier | None = None # 可选,阶段4实现 - ) -> AuthInfo | None: - """验证OAuth token(包含DPoP验证,如果启用)""" - # 提取Bearer token - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.lower().startswith("bearer "): - return None - - token = auth_header[7:] - auth_info = await self.token_verifier.verify_token(token) - - if not auth_info: - return None - - # DPoP验证(可选,阶段4实现) - verifier = dpop_verifier or self.dpop_verifier - if auth_info.cnf and auth_info.cnf.get("jkt") and verifier: - dpop_proof = request.headers.get("DPoP") - if not dpop_proof: - return None # DPoP-bound token必须包含DPoP证明 - - dpop_info = await verifier.verify_proof( - dpop_proof=dpop_proof, - method=request.method, - uri=str(request.url), - credential=token - ) - - if dpop_info.jwk_thumbprint != auth_info.cnf["jkt"]: - return None # 公钥不匹配 - - return auth_info - - # API Key 认证方案约定(方案 A):优先 X-API-Key header;可选 Authorization: Bearer (标准 scheme); - # 不解析非标准 ApiKey scheme;不在 token 内加前缀,由验证器顺序与 valid_keys 区分。 - class APIKeyVerifier(CredentialVerifier): - """API Key验证器""" - async def verify( - self, - request: Request, - dpop_verifier: DPoPProofVerifier | None = None - ) -> AuthInfo | None: - """验证API Key:优先 X-API-Key,回退 Bearer 并在 valid_keys 中查找""" - api_key = request.headers.get("X-API-Key") # 或 _get_header_ignore_case(request, "x-api-key") - if not api_key: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.strip().lower().startswith("bearer "): - bearer_token = auth_header[7:].strip() # len("Bearer ") - if bearer_token in self._valid_keys: - api_key = bearer_token - if not api_key or api_key not in self._valid_keys: - return None - - # ... 构造 AuthInfo/AccessToken - - # DPoP验证(可选,阶段4实现) - if dpop_verifier: - dpop_proof = request.headers.get("DPoP") - if dpop_proof: - await dpop_verifier.verify_proof(...) - - return auth_info - ``` - -#### 3.3.5 协议特定的元数据端点 - -**文件**: `src/mcp/server/auth/routes.py`(新增函数) - -**优先级**: 🟢 低 - -**改造内容**: -1. **新增协议元数据端点创建函数** - ```python - def create_protocol_metadata_routes( - protocol_id: str, - metadata: AuthProtocolMetadata - ) -> list[Route]: - """创建协议特定的元数据发现端点""" - # 例如: /.well-known/api-key-metadata - path = f"/.well-known/{protocol_id}-metadata" - handler = ProtocolMetadataHandler(metadata) - - return [ - Route( - path, - endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), - methods=["GET", "OPTIONS"], - ) - ] - ``` - -### 3.4 DPoP抽象组件(通用安全增强,可选) - -#### 3.4.1 客户端DPoP组件 - -**文件**: `src/mcp/client/auth/dpop.py`(新建) - -**优先级**: 🟢 低(可选安全增强) - -**改造内容**: -1. **DPoP证明生成器** - ```python - class DPoPProofGenerator(Protocol): - """DPoP证明生成器接口(协议无关)""" - def generate_proof( - self, - method: str, - uri: str, - credential: str | None = None, - nonce: str | None = None - ) -> str: ... - - def get_public_key_jwk(self) -> dict[str, Any]: ... - - class DPoPProofGeneratorImpl: - """DPoP证明生成器实现""" - def __init__(self, key_pair: DPoPKeyPair): - self.key_pair = key_pair - - def generate_proof(...) -> str: - # 实现DPoP JWT生成 - ``` - -2. **DPoP密钥对模型** - ```python - class DPoPKeyPair(BaseModel): - """DPoP密钥对(协议无关)""" - private_key_pem: str - public_key_jwk: dict[str, Any] - key_id: str | None = None - algorithm: str = "ES256" - - @classmethod - def generate(cls, algorithm: str = "ES256") -> "DPoPKeyPair": - # 生成密钥对 - ``` - -3. **DPoP存储接口** - ```python - class DPoPStorage(Protocol): - """DPoP密钥对存储接口(协议无关)""" - async def get_key_pair(self, protocol_id: str) -> DPoPKeyPair | None: ... - async def set_key_pair(self, protocol_id: str, key_pair: DPoPKeyPair) -> None: ... - ``` - -#### 3.4.2 服务器端DPoP组件 - -**文件**: `src/mcp/server/auth/dpop.py`(新建) - -**优先级**: 🟢 低(可选安全增强) - -**改造内容**: -1. **DPoP证明验证器** - ```python - class DPoPProofVerifier(Protocol): - """DPoP证明验证器接口(协议无关)""" - async def verify_proof( - self, - dpop_proof: str, - method: str, - uri: str, - credential: str | None = None - ) -> DPoPProofInfo: ... - - class DPoPProofVerifierImpl: - """DPoP证明验证器实现""" - def __init__(self, allowed_algorithms: list[str] = ["ES256", "RS256"]): - self.allowed_algorithms = allowed_algorithms - self.jti_cache: dict[str, int] = {} - - async def verify_proof(...) -> DPoPProofInfo: - # 实现DPoP证明验证 - # 包含重放保护 - ``` - -### 3.5 新增文件和模块 - -#### 3.5.1 协议抽象和接口 - -**新建文件**: `src/mcp/client/auth/protocol.py` -- `AuthProtocol`基础协议接口(必需方法) -- `ClientRegisterableProtocol`扩展接口(可选) -- `DPoPEnabledProtocol`扩展接口(可选,阶段4) -- `AuthProtocolMetadata`模型(或从shared导入) - -**新建文件**: `src/mcp/client/auth/registry.py` -- `AuthProtocolRegistry`类 -- 协议选择逻辑 - -#### 3.5.2 多协议提供者 - -**新建文件**: `src/mcp/client/auth/multi_protocol.py` -- `MultiProtocolAuthProvider`类 -- 协议发现和选择逻辑 -- 凭证管理 - -#### 3.5.3 OAuth协议实现 - -**新建文件**: `src/mcp/client/auth/protocols/oauth2.py` -- `OAuth2Protocol`类(实现`AuthProtocol`),**薄适配层** -- **不**迁移 OAuth 逻辑到此文件;`authenticate(context)` 内构造 `OAuthClientProvider`、填充上下文后调用 `provider.run_authentication(context.http_client)` 复用现有实现 -- 可选:实现`DPoPEnabledProtocol`扩展接口(阶段4) - -#### 3.5.4 服务器端验证器 - -**新建文件**: `src/mcp/server/auth/verifiers.py` -- `CredentialVerifier`协议接口 -- `OAuthTokenVerifier`(现有TokenVerifier的适配) -- `APIKeyVerifier` -- `MultiProtocolAuthBackend` -- Mutual TLS 验证器(若实现):从 TLS 连接/握手上下文中读取客户端证书并校验,不解析 HTTP Authorization 头 - -**新建文件**: `src/mcp/server/auth/handlers/discovery.py` -- `AuthorizationServersDiscoveryHandler`类 -- `ProtocolMetadataHandler`类 - -#### 3.5.5 DPoP组件 - -**新建文件**: `src/mcp/client/auth/dpop.py` -- `DPoPProofGenerator`接口和实现 -- `DPoPKeyPair`模型 -- `DPoPStorage`接口 - -**新建文件**: `src/mcp/server/auth/dpop.py` -- `DPoPProofVerifier`接口和实现 -- `DPoPProofInfo`模型 -- 重放保护逻辑 - -## 四、改造优先级和依赖关系 - -### 4.1 高优先级(必须首先实现) - -#### 阶段1:基础设施(1-2周) -1. **数据模型扩展**(3.1) - - `AuthProtocolMetadata`模型 - - `ProtectedResourceMetadata`扩展 - - `AuthCredentials`基类 - - 依赖:无 - -2. **协议抽象接口**(3.1.3) - - `AuthProtocol`接口定义 - - `CredentialVerifier`接口定义 - - 依赖:数据模型 - -3. **WWW-Authenticate头扩展**(3.2.1, 3.3.3) - - 客户端解析扩展 - - 服务器端构建扩展 - - 依赖:数据模型 - -### 4.2 中优先级(核心功能) - -#### 阶段2:核心功能(2-3周) -4. **协议发现机制**(3.2.2, 3.3.2) - - 统一能力发现端点 - - 协议特定的元数据发现 - - 依赖:数据模型、WWW-Authenticate扩展 - -5. **协议注册和选择**(3.2.3) - - 协议注册表 - - 协议选择逻辑 - - 依赖:协议抽象接口 - -6. **多协议提供者**(3.2.4, 3.5.2) - - `MultiProtocolAuthProvider` - - 协议切换机制 - - 依赖:协议注册、协议发现 - -7. **请求准备方法**(3.2.5) - - 协议特定的认证信息添加 - - 依赖:多协议提供者 - - 注意:DPoP集成是可选功能,在阶段4实现 - -8. **认证后端重构**(3.3.4) - - `MultiProtocolAuthBackend` - - 协议特定的验证器 - - 依赖:协议抽象接口 - - 注意:DPoP验证是可选功能,在阶段4实现 - -### 4.3 低优先级(向后兼容和优化) - -#### 阶段3:适配和优化(1-2周) -9. **OAuth适配**(3.2.4) - - `OAuth2Protocol`实现 - - `OAuthClientProvider`向后兼容包装 - - 依赖:多协议提供者 - -10. **PRM端点扩展**(3.3.1) - - 扩展`create_protected_resource_routes()` - - 依赖:数据模型 - -11. **凭证存储扩展**(3.2.6) - - `TokenStorage`协议扩展 - - 凭证序列化/反序列化 - - 依赖:凭证模型 - -#### 阶段4:可选安全增强(可选,1-2周) -12. **DPoP组件实现**(3.4,可选) - - DPoP证明生成和验证 - - DPoP存储 - - DPoP协议扩展接口实现 - - 依赖:协议抽象接口 - - 注意:这是可选功能,可以跳过 - -**DPoP Nonce**:阶段4 完成后可按 `docs/dpop-nonce-implementation-plan.md` 实现 RS/Client/AS 侧 nonce 支持;与当前 DPoP 基础实现正交。 - -### 4.4 依赖关系图 - -```mermaid -graph TD - A[数据模型扩展] --> B[协议抽象接口] - A --> C[WWW-Authenticate扩展] - B --> D[协议注册表] - B --> E[多协议提供者] - C --> F[协议发现机制] - D --> E - F --> E - E --> G[请求准备方法] - B --> H[认证后端] - E --> J[OAuth适配] - A --> K[凭证存储扩展] - I[DPoP组件-可选] -.-> G - I -.-> H -``` - -## 五、实施步骤 - -### 5.1 阶段1:基础设施(Week 1-2) - -**目标**:建立多协议支持的基础架构 - -**任务清单**: -- [ ] 实现`AuthProtocolMetadata`模型 -- [ ] 扩展`ProtectedResourceMetadata`添加`mcp_*`字段 -- [ ] 实现`AuthCredentials`基类和具体凭证类型 -- [ ] 定义`AuthProtocol`协议接口 -- [ ] 定义`CredentialVerifier`协议接口 -- [ ] 扩展WWW-Authenticate头解析(客户端) -- [ ] 扩展WWW-Authenticate头构建(服务器端) -- [ ] 编写单元测试 - -**验收标准**: -- 数据模型可以序列化/反序列化 -- WWW-Authenticate头可以正确解析和构建 -- 所有测试通过 - -**本阶段测试方案**: -- **单元/回归**:数据模型(`ProtectedResourceMetadata` 仅含 `resource`+`authorization_servers` 时校验 `mcp_auth_protocols`/`mcp_default_auth_protocol` 自动填充;`AuthProtocolMetadata`、`AuthCredentials`/`OAuthCredentials`/`APIKeyCredentials` 序列化与必填字段);客户端 `extract_field_from_www_auth` 不传 `auth_scheme` 时行为与改前一致,`extract_auth_protocols_from_www_auth`、`extract_default_protocol_from_www_auth`、`extract_protocol_preferences_from_www_auth` 解析正确;服务端 `RequireAuthMiddleware` 仅传 `(app, required_scopes, resource_metadata_url)` 时 WWW-Authenticate 仍为 Bearer,且含 `error`/`error_description`/可选 `resource_metadata`。**执行**:`uv run pytest tests/client/test_auth.py tests/server/auth/middleware/test_bearer_auth.py -v` -- **集成/交互**:使用 simple-auth(AS+RS)与 simple-auth-client 跑通 401→PRM/AS 发现→OAuth→Token→MCP 会话→`list`/`call get_time`/`quit`。详细步骤与检查项见 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md`。**脚本**:`./scripts/run_phase1_oauth2_integration_test.sh`(启动 AS/RS 并运行客户端,人工完成浏览器 OAuth 与 list/call/quit)。 - -### 5.2 阶段2:核心功能(Week 3-5) - -**目标**:实现多协议发现、选择和认证流程 - -**任务清单**: -- [ ] 实现统一能力发现端点(服务器端) -- [ ] 实现协议发现逻辑(客户端) -- [ ] 实现协议注册表 -- [ ] 实现协议选择逻辑 -- [ ] 实现`MultiProtocolAuthProvider` -- [ ] 实现协议特定的请求准备(不含DPoP,DPoP在阶段4) -- [ ] 实现`MultiProtocolAuthBackend` -- [ ] 实现OAuth验证器适配(不含DPoP验证,DPoP在阶段4) -- [ ] 编写集成测试 - -**验收标准**: -- 可以发现服务器支持的协议 -- 可以根据优先级选择协议 -- 可以执行多协议认证流程 -- 所有测试通过 - -**本阶段测试方案**: -- **单元**:协议注册表(注册/获取协议类、`select_protocol` 在给定 `available_protocols`/`default_protocol`/`preferences` 下的选择结果);协议发现(统一发现端点返回的解析、回退到 PRM/协议特定 well-known 的逻辑,若本阶段实现);`MultiProtocolAuthProvider`(在 mock 协议与存储下 `async_auth_flow` 的初始化、凭证校验、请求准备不含 DPoP 分支);服务端验证器与 `MultiProtocolAuthBackend`(按协议 ID 选择验证器,401/403 时 WWW-Authenticate 含 `auth_protocols`/`default_protocol`/`protocol_preferences` 当配置了这些参数时)。**集成**:若本阶段暴露统一发现端点,客户端请求 `/.well-known/authorization_servers` 得到协议列表,服务器 401 头含协议扩展字段时客户端能解析;回归:再次运行阶段1的 OAuth2 单元测试及 simple-auth + simple-auth-client 交互。**执行**:新增 `tests/client/auth/test_registry.py`、`tests/client/auth/test_multi_protocol_provider.py`(或等价),服务端 `tests/server/auth/test_verifiers.py` 或扩展现有 middleware 测试,集成可复用 `run_phase1_oauth2_integration_test.sh` 做回归。 - -#### 5.2.1 阶段2 新协议支持测试(可选协议选择与交互) - -阶段2 交付多协议发现、选择与认证流程后,需验证**能够选择非 OAuth 的认证协议并完成 MCP 交互**。使用**基于 simple-auth 与 simple-auth-client 的测试用服务端与客户端**,必要时对二者进行改造。新协议需覆盖 **API Key** 与 **Mutual TLS** 两种方式。 - -- **目标**:服务器同时或单独支持至少两种非 OAuth 协议(API Key、Mutual TLS),在 PRM 或 401 头中声明 `auth_protocols`/`default_protocol`(或 `protocol_preferences`);客户端能够发现协议列表、选择上述协议之一、使用对应凭证完成认证并与 MCP 服务交互(list tools、call tool、quit);API Key 与 Mutual TLS 两种路径均需覆盖。 -- **测试用服务端(基于 simple-auth)**:在 `examples/servers/` 下新增项目(如 `simple-auth-multiprotocol`),以 `examples/servers/simple-auth` 为蓝本复制并改造。在 RS 上增加对 API Key 与 Mutual TLS 两种非 OAuth 协议的支持;使用阶段2 的 `MultiProtocolAuthBackend`、API Key 验证器与 Mutual TLS 验证器(或占位实现);PRM 与 `RequireAuthMiddleware` 传入 `auth_protocols`(含 oauth2、api_key、mutual_tls)、`default_protocol`(及可选 `protocol_preferences`);统一发现端点若在本阶段实现则返回包含 oauth2、api_key、mutual_tls 的协议列表。保留原有 OAuth(AS+RS)能力。**验收**:RS 的 401 响应 WWW-Authenticate 含 `auth_protocols`(如 `oauth2 api_key mutual_tls`);合法 API Key 与合法客户端证书(Mutual TLS)的请求均能通过认证并访问 MCP 端点(如 list_tools、get_time)。 -- **测试用客户端(基于 simple-auth-client)**:在 `examples/clients/` 下新增项目(如 `simple-auth-multiprotocol-client`),以 `examples/clients/simple-auth-client` 为蓝本复制并改造。使用阶段2 的 `MultiProtocolAuthProvider` 与协议注册表;配置 API Key 与 Mutual TLS 协议实现及凭证(API Key 如环境变量或配置文件,Mutual TLS 如客户端证书/密钥路径);发现到服务器支持 api_key 或 mutual_tls 时能选择对应协议并完成认证(无需浏览器),建立 MCP 会话并支持 list/call/quit。**验收**:(1)能选择 API Key、携带 API Key 凭证发起请求、成功初始化会话并执行 list、call get_time、quit;(2)能选择 Mutual TLS、使用客户端证书发起请求、成功初始化会话并执行 list、call get_time、quit。 -- **测试场景与执行**:**API Key 路径**:启动多协议 RS(及可选 AS),启动多协议客户端并配置使用 API Key,执行 list→call get_time→quit,断言无认证错误且返回正确。**Mutual TLS 路径**:同一多协议 RS 启用 Mutual TLS 验证,启动多协议客户端并配置使用客户端证书,执行 list→call get_time→quit,断言无认证错误且返回正确。可选:编写 `scripts/run_phase2_multiprotocol_integration_test.sh`,支持通过参数或环境变量选择 API Key 或 Mutual TLS。原 simple-auth/simple-auth-client 仍用于 OAuth 回归;新示例仅用于阶段2 及之后的「新协议选择与交互」验证(API Key + Mutual TLS)。 - -### 5.3 阶段3:OAuth适配和优化(Week 6-7) - -**目标**:完成OAuth协议适配和向后兼容优化 - -**任务清单**: -- [ ] 实现`OAuth2Protocol`类(薄适配层,委托 `OAuthClientProvider.run_authentication`) -- [ ] 在`OAuthClientProvider`中新增`run_authentication(http_client, ...)`,复用现有 401 分支逻辑 -- [ ] 保持`OAuthClientProvider`现有 API 与行为不变(向后兼容) -- [ ] 实现PRM端点扩展 -- [ ] 实现凭证存储扩展 -- [ ] 编写集成测试 - -**验收标准**: -- 现有OAuth代码无需修改即可工作 -- 所有测试通过 -- 向后兼容性验证通过 - -**本阶段测试方案**: -- **单元**:`OAuth2Protocol`(`authenticate` 委托 `OAuthClientProvider.run_authentication`、`prepare_request`、`validate_credentials`、`discover_metadata` 在 mock 上下文下的行为);`OAuthClientProvider.run_authentication`(与现有 401 分支行为一致);`OAuthClientProvider` 对外 API 不变,现有 `tests/client/test_auth.py` 中所有 OAuth 相关用例仍通过;TokenStorage 扩展;PRM 端点扩展。**集成**:再次运行 simple-auth + simple-auth-client 全流程,确认 OAuth 仍为默认路径且行为一致;若有条件,同一 RS 同时支持 OAuth 与 API Key,客户端通过协议选择使用 OAuth,验证端到端多协议发现+OAuth 分支。**执行**:扩展现有 `tests/client/test_auth.py`,新增或扩展 `tests/server/auth/` 下 PRM/路由测试,集成继续使用 `scripts/run_phase1_oauth2_integration_test.sh` 及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md` 检查清单。 - -### 5.4 阶段4:可选安全增强(Week 8,可选) - -**目标**:实现DPoP作为可选安全增强组件 - -**任务清单**(可选,可以跳过): -- [ ] 实现DPoP证明生成器(`DPoPProofGenerator`) -- [ ] 实现DPoP证明验证器(`DPoPProofVerifier`) -- [ ] 实现DPoP存储接口(`DPoPStorage`) -- [ ] 实现`DPoPEnabledProtocol`扩展接口 -- [ ] 在`OAuth2Protocol`中实现`DPoPEnabledProtocol` -- [ ] 更新`MultiProtocolAuthProvider`的`_prepare_request`方法,集成DPoP -- [ ] 更新`OAuthTokenVerifier`和`APIKeyVerifier`,支持DPoP验证 -- [ ] 编写DPoP测试 - -**验收标准**: -- DPoP可以在OAuth和其他协议中使用 -- DPoP功能完全可选,不影响核心功能 -- 所有测试通过 - -**本阶段测试方案**: -- **单元**:DPoP 客户端(`DPoPProofGenerator` 生成 proof 的格式与算法,`DPoPStorage` 存取 key pair,`DPoPKeyPair.generate`);DPoP 服务端(`DPoPProofVerifier` 校验 proof、jti 重放保护、与 `OAuthTokenVerifier`/APIKey 验证器的组合);`DPoPEnabledProtocol`(`supports_dpop`、`get_dpop_proof_generator`、`initialize_dpop` 及在 `MultiProtocolAuthProvider._prepare_request` 中的调用当协议支持 DPoP 且启用时)。**集成**:OAuth+DPoP(启用 DPoP 的 OAuth 客户端对支持 DPoP 的 RS 发起请求,401 后带 DPoP proof 的 token 请求成功,若本阶段实现完整链路);回归:关闭 DPoP 时阶段1的 simple-auth+simple-auth-client 流程不变,所有既有 OAuth 单元与集成测试通过。**执行**:新增 `tests/client/auth/test_dpop.py`、`tests/server/auth/test_dpop.py`(或等价),集成可扩展现有示例或新增最小 DPoP 示例与脚本。 - -### 5.5 阶段5:新协议示例和文档(Week 9) - -**目标**:提供新协议实现示例和完整文档 - -**任务清单**: -- [ ] 实现API Key协议示例 -- [ ] 编写协议实现指南 -- [ ] 更新API文档 -- [ ] 编写迁移指南 -- [ ] 编写使用示例 -- [ ] 编写DPoP使用文档(如果阶段4完成) - -**验收标准**: -- 有完整的API Key协议实现示例 -- 文档清晰易懂 -- 开发者可以基于示例实现新协议 - -**本阶段测试方案**: -- **单元**:API Key 协议示例(协议实现满足 `AuthProtocol`,注册到注册表后能被选择并完成 `prepare_request`);文档与示例以文档评审与示例运行为主。**集成**:使用 API Key 示例客户端对支持 API Key 的示例服务完成一次认证与 MCP 调用(若提供示例);全回归:再次运行阶段1的 OAuth2 单元测试及 simple-auth+simple-auth-client 交互,确保文档与示例未引入回归。**执行**:示例目录下可增加 `tests/examples/` 或 README 中的「如何运行与验证」;主测试套件仍包含 `tests/client/test_auth.py` 与 `tests/server/auth/middleware/test_bearer_auth.py` 的完整运行。 - -## 六、向后兼容策略 - -### 6.1 数据模型兼容 - -- **保持RFC 9728必需字段不变**:`resource`和`authorization_servers`必须保持为必需字段 -- **`mcp_*`扩展字段为可选**:标准OAuth客户端可以忽略这些字段 -- **自动兼容处理**:如果`mcp_auth_protocols`为空,自动从`authorization_servers`创建OAuth协议元数据 - -### 6.2 API兼容 - -- **`OAuthClientProvider`保持现有API不变**,并新增 `run_authentication(http_client, ...)` 供多协议路径调用 -- **OAuth2Protocol 为薄适配层**,内部委托 `OAuthClientProvider.run_authentication`,不重复实现 OAuth 流程 -- **现有代码无需修改即可工作** - -### 6.3 行为兼容 - -- **默认行为**:如果没有协议声明,使用OAuth 2.0 -- **现有OAuth流程保持不变** -- **新协议作为可选功能添加** - -## 七、测试策略 - -### 7.1 单元测试 - -各阶段具体单元测试范围、新增用例及执行命令见第五节 5.1~5.5 中各阶段的「本阶段测试方案」。 - -- 数据模型序列化/反序列化测试 -- 协议发现逻辑测试 -- 协议选择逻辑测试 -- WWW-Authenticate头解析/构建测试 -- DPoP证明生成和验证测试 - -### 7.2 集成测试 - -阶段1~3 的 OAuth 回归均以 simple-auth + simple-auth-client 交互测试为准,详见 5.1 本阶段测试方案及 `tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md`;阶段2~5 的集成要点见对应小节。阶段2 完成后需增加新协议支持测试:基于 simple-auth 与 simple-auth-client 分别实现多协议测试服务端与客户端(见 5.2.1),验证可选择 **API Key** 与 **Mutual TLS** 两种非 OAuth 协议并完成 MCP 交互。 - -- 多协议认证流程测试 -- 协议切换测试 -- DPoP集成测试 -- 向后兼容性测试 - -### 7.3 端到端测试 - -多协议与 DPoP 端到端场景见 5.2~5.4 本阶段测试方案。 - -- 完整的多协议认证场景 -- OAuth + DPoP场景 -- API Key + DPoP场景 -- 协议降级场景 - -## 八、风险评估和缓解 - -### 8.1 技术风险 - -**风险1:向后兼容性破坏** -- **缓解**:保持现有API不变,内部使用适配器模式 -- **验证**:运行现有测试套件确保无回归 - -**风险2:性能影响** -- **缓解**:协议发现结果缓存,避免重复发现 -- **验证**:性能基准测试 - -**风险3:复杂度增加** -- **缓解**:清晰的抽象层次,良好的文档 -- **验证**:代码审查,架构评审 - -### 8.2 实施风险 - -**风险1:改造范围过大** -- **缓解**:分阶段实施,每个阶段都有可交付成果 -- **验证**:定期检查进度 - -**风险2:测试覆盖不足** -- **缓解**:每个阶段都有测试要求 -- **验证**:代码覆盖率检查 - -## 九、成功标准 - -### 9.1 功能完整性 - -- ✅ 支持OAuth 2.0协议(向后兼容) -- ✅ 支持至少一种新协议(如API Key) -- ✅ 支持DPoP作为可选安全增强 -- ✅ 协议自动发现和选择 -- ✅ 协议切换机制 - -### 9.2 代码质量 - -- ✅ 所有现有测试通过 -- ✅ 新功能测试覆盖率>80% -- ✅ 代码审查通过 -- ✅ 文档完整 - -### 9.3 向后兼容性 - -- ✅ 现有OAuth代码无需修改 -- ✅ 现有API保持不变 -- ✅ 现有行为保持一致 - -### 9.4 代码提交规范 - -本规范适用于本改造计划中每一项任务(TODO)完成后的提交,执行时须遵循: - -- 提交信息使用简洁英文(如 Add ... / Fix ...),仅描述本项改动。 -- 不包含 TODO 编号或内部任务引用。 -- 每次提交仅包含当前任务相关改动。 - -## 十、总结 - -本改造计划基于章节12.5和13的分析,结合DPoP抽象设计,提供了完整的多协议授权支持改造路线图。 - -**关键要点**: -1. **分阶段实施**:四个主要阶段(阶段4可选),每个阶段都有明确的交付物 -2. **向后兼容**:确保现有代码无需修改 -3. **协议抽象**:统一的接口,便于扩展 -4. **DPoP集成**:作为可选的通用安全增强组件(阶段4,可选) -5. **渐进式迁移**:可以逐步启用新功能 -6. **最小化接口**:基础接口只包含必需方法,可选功能通过扩展接口实现 - -**设计取舍**:OAuth 薄适配层、Generator 驱动 401 流程、API Key 方案 A、协议发现顺序、DPoP nonce 风险化解等详见 **十一、设计取舍与方案说明**。 - -**预计时间**: -- 核心功能:7周(阶段1-3) -- 完整功能(含DPoP):9周(阶段1-5,阶段4可选) - -**团队要求**: -- 熟悉OAuth 2.0协议 -- 熟悉Python异步编程 -- 熟悉HTTP协议和RESTful API设计 -- 熟悉测试驱动开发 - ---- - -## 十一、设计取舍与方案说明 - -本节汇总历史讨论中的关键设计决策,说明多方案并存时的取舍原因,便于后续实现与评审时对齐。 - -### 11.1 OAuth 逻辑复用与 401 流程驱动 - -多协议下的 OAuth 集成涉及两个相关联的取舍:**逻辑归属**(薄适配层 vs 逻辑迁移)与 **401 流程驱动方式**(Generator vs 新建 HTTP 客户端)。 - -**逻辑归属 — 可选方案**: -- **方案 A**:将 OAuth 逻辑迁出到 `OAuth2Protocol`,`OAuthClientProvider` 仅作为遗留入口 -- **方案 B**:`OAuth2Protocol` 为薄适配层,内部委托 `OAuthClientProvider.run_authentication`,OAuth 逻辑保持在 `oauth2.py` - -**401 流程驱动 — 可选方案**: -- **方案 A**:在 401 处理分支内新建 `httpx.AsyncClient`,独立发送 OAuth 相关请求 -- **方案 B**:使用共享的 `oauth_401_flow_generator`,由 `MultiProtocolAuthProvider` 驱动,所有 OAuth 步骤通过 `yield` 请求交由同一 `http_client` 发送 - -**取舍**:二者均采用 **方案 B**(薄适配层 + Generator 驱动)。 - -**原因**: -1. 最大程度复用现有 OAuth 实现,降低迁移风险与回归面;`OAuthClientProvider` 仍为 OAuth 逻辑唯一实现,避免双轨维护 -2. 薄适配层通过 `run_authentication(http_client, ...)` 调用,自然要求由调用方传入 `http_client`;Generator 模式使 `MultiProtocolAuthProvider` 作为驱动方,用同一 `http_client` 发送所有 OAuth 请求,二者设计上互锁 -3. 避免在 httpx 认证流程中创建新客户端导致的锁死锁风险;请求统一由 `httpx.Client(auth=provider)` 使用的同一 `http_client` 发送,行为可预测 -4. OAuth 流程(AS 发现、注册、授权、Token 交换)全部由 generator 产出请求,驱动方负责发送并回传响应;现有 `OAuthClientProvider` 用户无需改动 - -### 11.2 API Key 认证方案:标准 scheme vs 自定义 scheme - -**可选方案**: -- **方案 A**:使用 `X-API-Key` + 可选 `Authorization: Bearer `,不解析非标准 `Authorization: ApiKey ` -- **方案 B**:使用自定义 `Authorization: ApiKey ` scheme - -**取舍**:采用 **方案 A**。 - -**原因**: -1. `ApiKey` 非 IANA 注册的 HTTP Authentication scheme,方案 B 不符合 HTTP 规范 -2. RFC 6750 规定 Bearer token 为 opaque string,使用 `Bearer` 承载 API Key 语义合理 -3. 不在 token 内加前缀(如 `apikey:xxx`);区分由验证器顺序与 `valid_keys` 完成,符合 Bearer 不解析 token 内容的约定 - -### 11.3 Mutual TLS 与 IANA "Mutual" scheme - -**说明**:IANA 注册的 "Mutual" scheme(RFC 8120)表示基于密码的双向认证,与基于客户端证书的 Mutual TLS(mTLS)不同。 - -**取舍**:mTLS 在 TLS 握手层处理,不解析 HTTP `Authorization` 头;`Mutual TLS` 验证器从 TLS 连接/握手上下文读取客户端证书并校验。 - -### 11.4 协议发现顺序:PRM 优先 vs 统一发现 - -**取舍**:客户端协议发现顺序为:(1)PRM 的 `mcp_auth_protocols`(若已取得 PRM);(2)路径相对统一发现 `/.well-known/authorization_servers{path}`;(3)根路径统一发现 `/.well-known/authorization_servers`;(4)若上述均未得到协议列表且 PRM 含 `authorization_servers`,则 OAuth 回退。 - -**原因**:PRM 为 RFC 9728 标准且常与 401 的 `resource_metadata` 一起使用,优先使用可减少往返;统一发现作为补充;OAuth 回退保证仅实现 RFC 9728 的 RS 仍可被多协议客户端使用。 - -**鉴权发现日志**:发现过程在 `mcp.client.auth` 中输出 DEBUG 级别、英文、带 `[Auth discovery]` 前缀的日志(请求 URL、状态码及 200 时的可读响应体);客户端设置 `LOG_LEVEL=DEBUG` 可查看。 - -### 11.5 授权端点归属:AS 与 RS 的 URL 树 - -| 端点 | 归属 | 用途 | -|------|------|------| -| `/.well-known/oauth-authorization-server` | AS | OAuth 元数据(RFC 8414) | -| `/authorize`, `/token`, `/register`, `/introspect` | AS | OAuth 流程 | -| `/.well-known/oauth-protected-resource{path}` | RS | PRM(RFC 9728) | -| `/.well-known/authorization_servers` | RS | 统一协议发现(MCP 扩展) | - -**说明**:AS 与 RS 可能部署在不同主机(如 AS 9000、RS 8002);客户端按 11.4 所述顺序向 RS 获取协议列表(PRM 优先,再统一发现),再根据 `metadata_url` 向 AS 获取 OAuth 元数据。 - -### 11.6 TokenStorage 双契约:OAuthToken vs AuthCredentials - -**取舍**:`TokenStorage` 支持 `get_tokens() → AuthCredentials | OAuthToken | None` 与 `set_tokens(AuthCredentials | OAuthToken)`;`MultiProtocolAuthProvider` 内部负责 OAuthToken 与 OAuthCredentials 的转换。 - -**原因**:现有 OAuth 存储只处理 `OAuthToken`;多协议存储需处理 `APIKeyCredentials` 等。双契约 + 内部转换使 OAuth 存储无需改造即可工作。 - -### 11.7 DPoP Nonce 实现:风险与方案 - -DPoP nonce 详细方案见 `docs/dpop-nonce-implementation-plan.md`。关键取舍如下: - -| 风险 | 解决方案 | -|------|----------| -| **Token 请求 DPoP 缺失** | 单独 TODO 6a 实现 Token 请求 DPoP 与 400 `use_dpop_nonce` 重试,作为 AS nonce 前置依赖 | -| **AS 改造范围过大** | 拆分为 TODO 6b(SDK TokenHandler DPoP+nonce)与 TODO 6c(simple-auth 示例 DPoP-bound token),各 ≤300 行 | - -**分阶段**:先 RS + Client nonce(TODO 1–5),后 AS nonce(TODO 6a–6c),降低单次改动量。 - -### 11.8 测试 skipped 说明 - -全量回归中约有 95 个 skipped: -- **约 90+** 来自 `tests/experimental/tasks/test_spec_compliance.py`:占位测试,内部 `pytest.skip("TODO")`,与多协议改造无关 -- **其余**:平台条件(如 Windows 专用、无 `tee` 命令)、显式跳过(如 SSE timeout 相关 bug 测试) - -改造过程中不修改上述 skip 逻辑。 From a3c84c48fbfce8ffcb61ca4b65d851d66c94d5e7 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:12:15 +0800 Subject: [PATCH 47/64] refactor(server): simplify streamable HTTP request handling --- src/mcp/server/streamable_http.py | 226 +++++++++++++++++------------- 1 file changed, 125 insertions(+), 101 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 5636eec87..c15dd4a2b 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -17,6 +17,7 @@ import anyio import pydantic_core +from anyio.abc import ObjectReceiveStream, ObjectSendStream from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import ValidationError from sse_starlette import EventSourceResponse @@ -427,6 +428,110 @@ async def _validate_accept_header(self, request: Request, scope: Scope, send: Se return False return True + async def _handle_post_request_json_mode( + self, + *, + scope: Scope, + request: Request, + receive: Receive, + send: Send, + writer: ObjectSendStream[SessionMessage], + message: JSONRPCRequest, + request_id: str, + request_stream_reader: ObjectReceiveStream[EventMessage], + ) -> None: + metadata = ServerMessageMetadata(request_context=request) + session_message = SessionMessage(message, metadata=metadata) + await writer.send(session_message) + try: + # Process messages from the request-specific stream. + response_message: JSONRPCResponse | JSONRPCError | None = None + + async for event_message in request_stream_reader: # pragma: no branch + if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): + response_message = event_message.message + break + else: # pragma: no cover + logger.debug("received: %s", event_message.message.method) + + if response_message: + response = self._create_json_response(response_message) + await response(scope, receive, send) + else: # pragma: no cover + logger.error("No response message received before stream closed") + response = self._create_error_response( + "Error processing request: No response received", + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + await response(scope, receive, send) + except Exception: # pragma: no cover + logger.exception("Error processing JSON response") + response = self._create_error_response( + "Error processing request", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(scope, receive, send) + finally: + await self._clean_up_memory_streams(request_id) + + async def _handle_post_request_sse_mode( + self, + *, + scope: Scope, + request: Request, + receive: Receive, + send: Send, + writer: ObjectSendStream[SessionMessage], + message: JSONRPCRequest, + request_id: str, + request_stream_reader: ObjectReceiveStream[EventMessage], + protocol_version: str, + ) -> None: # pragma: no cover + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + self._sse_stream_writers[request_id] = sse_stream_writer + + async def sse_writer() -> None: + try: + async with sse_stream_writer, request_stream_reader: + await self._maybe_send_priming_event(request_id, sse_stream_writer, protocol_version) + async for event_message in request_stream_reader: + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): + break + except anyio.ClosedResourceError: + logger.debug("SSE stream closed by close_sse_stream()") + except Exception: + logger.exception("Error in SSE writer") + finally: + logger.debug("Closing SSE writer") + self._sse_stream_writers.pop(request_id, None) + await self._clean_up_memory_streams(request_id) + + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + **({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}), + } + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=sse_writer, + headers=headers, + ) + + try: + async with anyio.create_task_group() as tg: + tg.start_soon(response, scope, receive, send) + session_message = self._create_session_message(message, request, request_id, protocol_version) + await writer.send(session_message) + except Exception: + logger.exception("SSE response error") + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + await self._clean_up_memory_streams(request_id) + async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: """Handle POST requests containing JSON-RPC messages.""" writer = self._read_stream_writer @@ -527,110 +632,29 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re request_stream_reader = self._request_streams[request_id][1] if self.is_json_response_enabled: - # Process the message - metadata = ServerMessageMetadata(request_context=request) - session_message = SessionMessage(message, metadata=metadata) - await writer.send(session_message) - try: - # Process messages from the request-specific stream - # We need to collect all messages until we get a response - response_message = None - - # Use similar approach to SSE writer for consistency - async for event_message in request_stream_reader: # pragma: no branch - # If it's a response, this is what we're waiting for - if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): - response_message = event_message.message - break - # For notifications and request, keep waiting - else: # pragma: no cover - logger.debug(f"received: {event_message.message.method}") - - # At this point we should have a response - if response_message: - # Create JSON response - response = self._create_json_response(response_message) - await response(scope, receive, send) - else: # pragma: no cover - # This shouldn't happen in normal operation - logger.error("No response message received before stream closed") - response = self._create_error_response( - "Error processing request: No response received", - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - await response(scope, receive, send) - except Exception: # pragma: no cover - logger.exception("Error processing JSON response") - response = self._create_error_response( - "Error processing request", - HTTPStatus.INTERNAL_SERVER_ERROR, - INTERNAL_ERROR, - ) - await response(scope, receive, send) - finally: - await self._clean_up_memory_streams(request_id) + await self._handle_post_request_json_mode( + scope=scope, + request=request, + receive=receive, + send=send, + writer=writer, + message=message, + request_id=request_id, + request_stream_reader=request_stream_reader, + ) else: # pragma: no cover - # Create SSE stream - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) - - # Store writer reference so close_sse_stream() can close it - self._sse_stream_writers[request_id] = sse_stream_writer - - async def sse_writer(): - # Get the request ID from the incoming request message - try: - async with sse_stream_writer, request_stream_reader: - # Send priming event for SSE resumability - await self._maybe_send_priming_event(request_id, sse_stream_writer, protocol_version) - - # Process messages from the request-specific stream - async for event_message in request_stream_reader: - # Build the event data - event_data = self._create_event_data(event_message) - await sse_stream_writer.send(event_data) - - # If response, remove from pending streams and close - if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): - break - except anyio.ClosedResourceError: - # Expected when close_sse_stream() is called - logger.debug("SSE stream closed by close_sse_stream()") - except Exception: - logger.exception("Error in SSE writer") - finally: - logger.debug("Closing SSE writer") - self._sse_stream_writers.pop(request_id, None) - await self._clean_up_memory_streams(request_id) - - # Create and start EventSourceResponse - # SSE stream mode (original behavior) - # Set up headers - headers = { - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "Content-Type": CONTENT_TYPE_SSE, - **({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}), - } - response = EventSourceResponse( - content=sse_stream_reader, - data_sender_callable=sse_writer, - headers=headers, + await self._handle_post_request_sse_mode( + scope=scope, + request=request, + receive=receive, + send=send, + writer=writer, + message=message, + request_id=request_id, + request_stream_reader=request_stream_reader, + protocol_version=protocol_version, ) - # Start the SSE response (this will send headers immediately) - try: - # First send the response to establish the SSE connection - async with anyio.create_task_group() as tg: - tg.start_soon(response, scope, receive, send) - # Then send the message to be processed by the server - session_message = self._create_session_message(message, request, request_id, protocol_version) - await writer.send(session_message) - except Exception: - logger.exception("SSE response error") - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - await self._clean_up_memory_streams(request_id) - except anyio.ClosedResourceError as err: # pragma: no cover # Session terminated (e.g., DELETE processed) while handling POST. # Response may have already been sent (e.g., 202 for notifications). From 4b1846e99a2dd4e1f7502e6507fe85f19a9e9759 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:12:28 +0800 Subject: [PATCH 48/64] refactor(auth-server): tighten token handling and verifiers --- src/mcp/server/auth/dpop.py | 23 ++- src/mcp/server/auth/handlers/discovery.py | 3 +- src/mcp/server/auth/handlers/token.py | 201 ++++++++++--------- src/mcp/server/auth/routes.py | 6 +- src/mcp/server/auth/verifiers.py | 46 ++--- tests/server/auth/test_discovery.py | 8 +- tests/server/auth/test_protected_resource.py | 2 +- tests/server/auth/test_verifiers.py | 11 +- 8 files changed, 151 insertions(+), 149 deletions(-) diff --git a/src/mcp/server/auth/dpop.py b/src/mcp/server/auth/dpop.py index 19cc65183..2f3e4f9b3 100644 --- a/src/mcp/server/auth/dpop.py +++ b/src/mcp/server/auth/dpop.py @@ -1,5 +1,4 @@ -""" -DPoP (Demonstrating Proof-of-Possession) server-side verification. +"""DPoP (Demonstrating Proof-of-Possession) server-side verification. RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP). Provides DPoPProofVerifier for validating DPoP proof JWTs and jti replay protection. @@ -196,22 +195,26 @@ def _compute_thumbprint(jwk: dict[str, Any]) -> str: kty = jwk.get("kty") if kty == "EC": canonical = { - "crv": jwk["crv"], - "kty": "EC", - "x": jwk["x"], + "crv": jwk["crv"], + "kty": "EC", + "x": jwk["x"], "y": jwk["y"], } elif kty == "RSA": canonical = { - "e": jwk["e"], - "kty": "RSA", + "e": jwk["e"], + "kty": "RSA", "n": jwk["n"], } else: raise DPoPVerificationError("invalid_dpop_proof", f"Unsupported kty: {kty}") - return base64.urlsafe_b64encode( - hashlib.sha256(json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode()).digest() - ).decode().rstrip("=") + return ( + base64.urlsafe_b64encode( + hashlib.sha256(json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode()).digest() + ) + .decode() + .rstrip("=") + ) def extract_dpop_proof(headers: dict[str, str]) -> str | None: diff --git a/src/mcp/server/auth/handlers/discovery.py b/src/mcp/server/auth/handlers/discovery.py index 152c1a26c..0a2ee0fe1 100644 --- a/src/mcp/server/auth/handlers/discovery.py +++ b/src/mcp/server/auth/handlers/discovery.py @@ -10,8 +10,7 @@ @dataclass class AuthorizationServersDiscoveryHandler: - """ - Handler for /.well-known/authorization_servers. + """Handler for /.well-known/authorization_servers. Returns JSON with protocols (list of AuthProtocolMetadata), optional default_protocol, and optional protocol_preferences. Clients use "protocols" for discovery. diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index b089da690..1e8affd32 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -50,6 +50,7 @@ class ClientCredentialsRequest(BaseModel): # RFC 8707 resource indicator resource: str | None = Field(None, description="Resource indicator for the token") + TokenRequest = Annotated[ AuthorizationCodeRequest | RefreshTokenRequest | ClientCredentialsRequest, Field(discriminator="grant_type"), @@ -119,136 +120,138 @@ async def handle(self, request: Request): ) ) - if token_request.grant_type not in client_info.grant_types: # pragma: no cover - return self.response( - TokenErrorResponse( + response_obj: TokenSuccessResponse | TokenErrorResponse = TokenErrorResponse( + error="invalid_request", + error_description="Token exchange failed", + ) + tokens: OAuthToken | None = None + + while True: + if token_request.grant_type not in client_info.grant_types: # pragma: no cover + response_obj = TokenErrorResponse( error="unsupported_grant_type", error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"), ) - ) - - tokens: OAuthToken - - match token_request: - case AuthorizationCodeRequest(): - auth_code = await self.provider.load_authorization_code(client_info, token_request.code) - if auth_code is None or auth_code.client_id != token_request.client_id: - # if code belongs to different client, pretend it doesn't exist - return self.response( - TokenErrorResponse( + break + + match token_request: + case AuthorizationCodeRequest(): + auth_code = await self.provider.load_authorization_code(client_info, token_request.code) + if auth_code is None or auth_code.client_id != token_request.client_id: + # if code belongs to different client, pretend it doesn't exist + response_obj = TokenErrorResponse( error="invalid_grant", error_description="authorization code does not exist", ) - ) + break - # make auth codes expire after a deadline - # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5 - if auth_code.expires_at < time.time(): - return self.response( - TokenErrorResponse( + # make auth codes expire after a deadline + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5 + if auth_code.expires_at < time.time(): + response_obj = TokenErrorResponse( error="invalid_grant", error_description="authorization code has expired", ) + break + + # verify redirect_uri doesn't change between /authorize and /tokens + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 + if auth_code.redirect_uri_provided_explicitly: + authorize_request_redirect_uri = auth_code.redirect_uri + else: # pragma: no cover + authorize_request_redirect_uri = None + + # Convert both sides to strings for comparison to handle AnyUrl vs string issues + token_redirect_str = ( + str(token_request.redirect_uri) if token_request.redirect_uri is not None else None + ) + auth_redirect_str = ( + str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None ) - # verify redirect_uri doesn't change between /authorize and /tokens - # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 - if auth_code.redirect_uri_provided_explicitly: - authorize_request_redirect_uri = auth_code.redirect_uri - else: # pragma: no cover - authorize_request_redirect_uri = None - - # Convert both sides to strings for comparison to handle AnyUrl vs string issues - token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None - auth_redirect_str = ( - str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None - ) - - if token_redirect_str != auth_redirect_str: - return self.response( - TokenErrorResponse( + if token_redirect_str != auth_redirect_str: + response_obj = TokenErrorResponse( error="invalid_request", - error_description=("redirect_uri did not match the one used when creating auth code"), + error_description="redirect_uri did not match the one used when creating auth code", ) - ) + break - # Verify PKCE code verifier - sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest() - hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=") + # Verify PKCE code verifier + sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest() + hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=") - if hashed_code_verifier != auth_code.code_challenge: - # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 - return self.response( - TokenErrorResponse( + if hashed_code_verifier != auth_code.code_challenge: + # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 + response_obj = TokenErrorResponse( error="invalid_grant", error_description="incorrect code_verifier", ) - ) - - try: - # Exchange authorization code for tokens - tokens = await self.provider.exchange_authorization_code(client_info, auth_code) - except TokenError as e: - return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) - - case RefreshTokenRequest(): # pragma: no branch - refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) - if refresh_token is None or refresh_token.client_id != token_request.client_id: - # if token belongs to different client, pretend it doesn't exist - return self.response( - TokenErrorResponse( + break + + try: + # Exchange authorization code for tokens + tokens = await self.provider.exchange_authorization_code(client_info, auth_code) + except TokenError as e: + response_obj = TokenErrorResponse(error=e.error, error_description=e.error_description) + break + + case RefreshTokenRequest(): # pragma: no branch + refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) + if refresh_token is None or refresh_token.client_id != token_request.client_id: + # if token belongs to different client, pretend it doesn't exist + response_obj = TokenErrorResponse( error="invalid_grant", error_description="refresh token does not exist", ) - ) + break - if refresh_token.expires_at and refresh_token.expires_at < time.time(): - # if the refresh token has expired, pretend it doesn't exist - return self.response( - TokenErrorResponse( + if refresh_token.expires_at and refresh_token.expires_at < time.time(): + # if the refresh token has expired, pretend it doesn't exist + response_obj = TokenErrorResponse( error="invalid_grant", error_description="refresh token has expired", ) - ) + break - # Parse scopes if provided - scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes + # Parse scopes if provided + scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes - for scope in scopes: - if scope not in refresh_token.scopes: - return self.response( - TokenErrorResponse( + for scope in scopes: + if scope not in refresh_token.scopes: + response_obj = TokenErrorResponse( error="invalid_scope", - error_description=(f"cannot request scope `{scope}` not provided by refresh token"), + error_description=f"cannot request scope `{scope}` not provided by refresh token", ) - ) - - try: - # Exchange refresh token for new tokens - tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) - except TokenError as e: - return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) - - case ClientCredentialsRequest(): - # Exchange client credentials for access token - scope_str = token_request.scope or getattr(client_info, "scope", None) or "" - scopes = scope_str.split(" ") if scope_str else [] - exchange = getattr(self.provider, "exchange_client_credentials", None) - if exchange is None: - return self.response( - TokenErrorResponse( + break + else: + try: + # Exchange refresh token for new tokens + tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) + except TokenError as e: + response_obj = TokenErrorResponse(error=e.error, error_description=e.error_description) + break + + case ClientCredentialsRequest(): + # Exchange client credentials for access token + scope_str = token_request.scope or getattr(client_info, "scope", None) or "" + scopes = scope_str.split(" ") if scope_str else [] + exchange = getattr(self.provider, "exchange_client_credentials", None) + if exchange is None: + response_obj = TokenErrorResponse( error="unsupported_grant_type", error_description="client_credentials is not supported by this authorization server", ) - ) - try: - tokens = await exchange(client_info, scopes=scopes, resource=token_request.resource) - except TokenError as e: - return self.response( - TokenErrorResponse( - error=e.error, - error_description=e.error_description, - ) - ) + break + try: + tokens = await exchange(client_info, scopes=scopes, resource=token_request.resource) + except TokenError as e: + response_obj = TokenErrorResponse(error=e.error, error_description=e.error_description) + break + + if tokens is None: + break + + response_obj = tokens + break - return self.response(tokens) + return self.response(response_obj) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 1266facf9..fda3dd629 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -10,7 +10,6 @@ from starlette.types import ASGIApp from mcp.server.auth.handlers.authorize import AuthorizationHandler -from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler from mcp.server.auth.handlers.discovery import AuthorizationServersDiscoveryHandler from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler from mcp.server.auth.handlers.register import RegistrationHandler @@ -165,7 +164,7 @@ def build_metadata( scopes_supported=client_registration_options.valid_scopes, response_types_supported=["code"], response_modes_supported=None, - grant_types_supported=["authorization_code", "refresh_token"], + grant_types_supported=["authorization_code", "refresh_token", "client_credentials"], token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], token_endpoint_auth_signing_alg_values_supported=None, service_documentation=service_documentation_url, @@ -267,8 +266,7 @@ def create_authorization_servers_discovery_routes( default_protocol: str | None = None, protocol_preferences: dict[str, int] | None = None, ) -> list[Route]: - """ - Create routes for unified authorization servers discovery (/.well-known/authorization_servers). + """Create routes for unified authorization servers discovery (/.well-known/authorization_servers). Args: protocols: List of supported auth protocol metadata. diff --git a/src/mcp/server/auth/verifiers.py b/src/mcp/server/auth/verifiers.py index 59592f09d..d27b508e9 100644 --- a/src/mcp/server/auth/verifiers.py +++ b/src/mcp/server/auth/verifiers.py @@ -1,7 +1,6 @@ -""" -多协议凭证验证器。 +"""Multi-protocol credential verifiers. -提供 CredentialVerifier 协议及 OAuthTokenVerifier 实现,供 MultiProtocolAuthBackend 按协议尝试校验。 +Defines the CredentialVerifier protocol and concrete implementations used by MultiProtocolAuthBackend. """ from typing import Any, Protocol @@ -17,32 +16,34 @@ class CredentialVerifier(Protocol): - """凭证验证器协议:按请求校验认证信息,可选 DPoP 校验(阶段4 实现)。""" + """Credential verifier interface. + + Verifies request authentication information. Optionally performs DPoP verification when a verifier is provided. + """ async def verify( self, request: Request, dpop_verifier: Any = None, ) -> AccessToken | None: - """ - 校验请求中的凭证。 + """Verify credentials from an incoming request. Args: - request: 待校验的请求。 - dpop_verifier: 可选 DPoP 校验器,阶段4 再使用。 + request: Incoming request. + dpop_verifier: Optional DPoP verifier. Returns: - 校验成功时返回 AccessToken,否则返回 None。 + AccessToken if verification succeeds, otherwise None. """ ... class OAuthTokenVerifier: - """ - OAuth Bearer/DPoP 凭证验证器。 + """OAuth Bearer/DPoP credential verifier. - 支持 Bearer 和 DPoP 两种 token 类型。当提供 dpop_verifier 时,会验证 DPoP proof - 的签名、htm/htu/iat/ath 等声明。注:cnf.jkt 绑定检查暂未实现(需 AccessToken 扩展)。 + Supports both Bearer and DPoP-bound access tokens. When a dpop_verifier is provided, it verifies DPoP proof + signature and claims (htm/htu/iat/ath). Note: cnf.jkt binding checks are not implemented yet (requires + AccessToken extension). """ def __init__(self, token_verifier: TokenVerifier) -> None: @@ -63,10 +64,10 @@ async def verify( if auth_header.lower().startswith(DPOP_PREFIX.lower()): # DPoP-bound access token (Authorization: DPoP ) - token = auth_header[len(DPOP_PREFIX):].strip() + token = auth_header[len(DPOP_PREFIX) :].strip() is_dpop_bound = True elif auth_header.lower().startswith(BEARER_PREFIX.lower()): - token = auth_header[len(BEARER_PREFIX):].strip() + token = auth_header[len(BEARER_PREFIX) :].strip() if not token: return None @@ -112,12 +113,12 @@ def _get_header_ignore_case(request: Request, name: str) -> str | None: class APIKeyVerifier: - """ - API Key 凭证验证器。 + """API key credential verifier. - 优先从 X-API-Key header 读取;可选从 Authorization: Bearer 读取并在 valid_keys 中查找。 - 不解析非标准 ApiKey scheme;DPoP 占位,阶段4 再实现。 - 可选 scopes:校验通过时赋予的 scope 列表,用于满足 RequireAuthMiddleware 的 required_scopes。 + Prefers reading ``X-API-Key`` header; optionally falls back to ``Authorization: Bearer `` and matches it + against valid_keys. This verifier does not parse non-standard ``ApiKey`` schemes. + + Optionally assigns ``scopes`` to the verified token, which can satisfy RequireAuthMiddleware's required_scopes. """ def __init__(self, valid_keys: set[str], scopes: list[str] | None = None) -> None: @@ -147,10 +148,9 @@ async def verify( class MultiProtocolAuthBackend: - """ - 多协议认证后端。 + """Multi-protocol authentication backend. - 按顺序遍历 verifiers,第一个校验成功的返回其 AccessToken,否则返回 None。 + Iterates over verifiers in order and returns the first successful AccessToken, or None if all fail. """ def __init__(self, verifiers: list[CredentialVerifier]) -> None: diff --git a/tests/server/auth/test_discovery.py b/tests/server/auth/test_discovery.py index 713ef0286..9fbb8cdd5 100644 --- a/tests/server/auth/test_discovery.py +++ b/tests/server/auth/test_discovery.py @@ -62,9 +62,7 @@ async def test_discovery_response_parseable_by_client() -> None: protocols=[AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0")], ) app = Starlette(routes=routes) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com" - ) as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com") as client: response = await client.get("/.well-known/authorization_servers") assert response.status_code == 200 data = response.json() @@ -82,9 +80,7 @@ async def test_discovery_routes_minimal_protocols_only() -> None: protocols=[AuthProtocolMetadata(protocol_id="api_key", protocol_version="1")], ) app = Starlette(routes=routes) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com" - ) as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://mcptest.com") as client: response = await client.get("/.well-known/authorization_servers") assert response.status_code == 200 data = response.json() diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 94dd61cf1..854e9abc8 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -227,7 +227,7 @@ async def multiprotocol_client(multiprotocol_app: Starlette): @pytest.mark.anyio async def test_metadata_includes_mcp_auth_protocols(multiprotocol_client: httpx.AsyncClient) -> None: - """PRM endpoint returns mcp_auth_protocols, mcp_default_auth_protocol, mcp_auth_protocol_preferences when provided.""" + """PRM returns mcp_* fields when explicitly configured.""" response = await multiprotocol_client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() diff --git a/tests/server/auth/test_verifiers.py b/tests/server/auth/test_verifiers.py index 4c551183b..d3923f2dc 100644 --- a/tests/server/auth/test_verifiers.py +++ b/tests/server/auth/test_verifiers.py @@ -33,6 +33,7 @@ def _request_with_headers(headers: list[tuple[str, str]]) -> Request: scope: dict[str, Any] = {"type": "http", "headers": []} if headers: from starlette.datastructures import Headers + h = Headers(dict(headers)) scope["headers"] = h.raw return Request(scope) @@ -167,10 +168,12 @@ async def test_multi_protocol_backend_returns_first_success() -> None: @pytest.mark.anyio async def test_multi_protocol_backend_returns_none_when_all_fail() -> None: - backend = MultiProtocolAuthBackend(verifiers=[ - OAuthTokenVerifier(cast(Any, _MockTokenVerifier())), - APIKeyVerifier(valid_keys=set()), - ]) + backend = MultiProtocolAuthBackend( + verifiers=[ + OAuthTokenVerifier(cast(Any, _MockTokenVerifier())), + APIKeyVerifier(valid_keys=set()), + ] + ) request = _request_with_headers([]) result = await backend.verify(request) assert result is None From c51a1037568faa3af8976c62695545ae08a63997 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:12:41 +0800 Subject: [PATCH 49/64] chore(auth): translate comments and docstrings to English --- src/mcp/client/auth/_oauth_401_flow.py | 41 ++-- src/mcp/client/auth/multi_protocol.py | 223 ++++++++++------------ src/mcp/client/auth/oauth2.py | 11 +- src/mcp/client/auth/protocol.py | 76 ++++---- src/mcp/client/auth/protocols/__init__.py | 2 +- src/mcp/client/auth/protocols/oauth2.py | 51 +++-- src/mcp/client/auth/registry.py | 51 +++-- src/mcp/client/auth/utils.py | 14 +- src/mcp/shared/auth.py | 10 +- 9 files changed, 209 insertions(+), 270 deletions(-) diff --git a/src/mcp/client/auth/_oauth_401_flow.py b/src/mcp/client/auth/_oauth_401_flow.py index 347142cd9..da26c024c 100644 --- a/src/mcp/client/auth/_oauth_401_flow.py +++ b/src/mcp/client/auth/_oauth_401_flow.py @@ -1,8 +1,7 @@ -""" -共享的 OAuth 401/403 流程 generator。 +"""Shared OAuth 401/403 flow generators. -供 OAuthClientProvider 与 MultiProtocolAuthProvider 复用,通过 yield 发送请求, -实现单 client、无死锁的 OAuth 发现与认证流程。 +These generators are reused by OAuthClientProvider and MultiProtocolAuthProvider. They yield requests so the caller +can send them with a single HTTP client, avoiding deadlocks while performing OAuth discovery and authentication. """ import logging @@ -36,14 +35,11 @@ class _OAuth401FlowProvider(Protocol): """Provider interface for oauth_401_flow_generator (OAuthClientProvider duck type).""" @property - def context(self) -> Any: - ... + def context(self) -> Any: ... - async def _perform_authorization(self) -> httpx.Request: - ... + async def _perform_authorization(self) -> httpx.Request: ... - async def _handle_token_response(self, response: httpx.Response) -> None: - ... + async def _handle_token_response(self, response: httpx.Response) -> None: ... logger = logging.getLogger(__name__) @@ -56,17 +52,18 @@ async def oauth_401_flow_generator( *, initial_prm: "ProtectedResourceMetadata | None" = None, ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """ - OAuth 401 流程:PRM 发现(可跳过)→ AS 发现 → scope → 注册/CIMD → 授权码 → Token 交换。 + """OAuth 401 flow: PRM discovery (optional) → AS metadata discovery → scope → registration/CIMD → auth → token. - 通过 yield 发出请求,由调用方负责发送并传回响应。供 OAuthClientProvider 与 - MultiProtocolAuthProvider 复用,实现单 client、yield 模式的 OAuth 流程。 + The generator yields requests, and the caller is responsible for sending them and feeding responses back into the + generator. This enables a single-client, yield-based OAuth flow usable by both OAuthClientProvider and + MultiProtocolAuthProvider. Args: - provider: OAuthClientProvider 实例,需有 context、_perform_authorization、_handle_token_response - request: 触发 401 的原始请求 - response_401: 401 响应 - initial_prm: 若提供则跳过 PRM 发现(MultiProtocolAuthProvider 已事先完成) + provider: Provider instance (OAuthClientProvider duck type). Must provide ``context``, + ``_perform_authorization()``, and ``_handle_token_response()``. + request: The original request that triggered 401. + response_401: The 401 response. + initial_prm: If provided, PRM discovery is skipped (MultiProtocolAuthProvider may pre-discover it). """ ctx = provider.context @@ -94,9 +91,7 @@ async def oauth_401_flow_generator( logger.debug("Protected resource metadata discovery failed: %s", url) # Step 2: Discover OAuth Authorization Server Metadata (OASM) - asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( - ctx.auth_server_url, ctx.server_url - ) + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(ctx.auth_server_url, ctx.server_url) for url in asm_discovery_urls: oauth_metadata_request = create_oauth_metadata_request(url) @@ -153,9 +148,7 @@ async def oauth_403_flow_generator( request: httpx.Request, response_403: httpx.Response, ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """ - OAuth 403 insufficient_scope 流程:更新 scope → 重新授权 → Token 交换。 - """ + """OAuth 403 insufficient_scope flow: update scope → re-authorize → token exchange.""" ctx = provider.context error = extract_field_from_www_auth(response_403, "error") diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index a28c914dc..12dcb6a50 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -1,27 +1,30 @@ -""" -多协议认证提供者。 - -提供基于协议注册表与发现的统一 HTTP 认证流程,支持 OAuth 2.0、API Key 等协议。 - -TokenStorage 双契约与转换约定 ----------------------------- -- **oauth2 契约**(OAuthClientProvider 使用):get_tokens() -> OAuthToken | None, - set_tokens(OAuthToken);另可有 get_client_info/set_client_info。 -- **multi_protocol 契约**(本模块 TokenStorage):get_tokens() -> AuthCredentials | OAuthToken | None, - set_tokens(AuthCredentials | OAuthToken)。 -- **转换约定**:MultiProtocolAuthProvider 在调用方做转换,不扩展协议方法: - - 取回时:_get_credentials() 调用 storage.get_tokens(),若得到 OAuthToken 则经 - _oauth_token_to_credentials 转为 OAuthCredentials。 - - 写入时:_discover_and_authenticate 得到 AuthCredentials 后经 _credentials_to_storage - 转为 OAuthToken(仅 OAuthCredentials 转 OAuthToken,其他凭证原样),再调用 - storage.set_tokens(to_store)。 -- 因此仅实现 get_tokens/set_tokens(OAuthToken) 的旧存储可直接用于 MultiProtocolAuthProvider, - 无需改存储实现。可选使用 OAuthTokenStorageAdapter 将此类存储包装为满足 multi_protocol 契约。 +"""Multi-protocol authentication provider. + +This module provides a unified HTTP authentication flow based on protocol discovery and an injected protocol registry. +It supports OAuth 2.0, API keys, and other pluggable auth protocols. + +Token storage: dual contract and conversion rules +------------------------------------------------- +- **oauth2 contract** (used by :class:`~mcp.client.auth.oauth2.OAuthClientProvider`): + ``get_tokens() -> OAuthToken | None`` and ``set_tokens(OAuthToken)``; optionally + ``get_client_info()/set_client_info()``. +- **multi_protocol contract** (``TokenStorage`` in this module): + ``get_tokens() -> AuthCredentials | OAuthToken | None`` and ``set_tokens(AuthCredentials | OAuthToken)``. +- **conversion rule**: conversions happen in the provider, without expanding protocol APIs: + - Read path: ``_get_credentials()`` calls ``storage.get_tokens()``. If it returns an ``OAuthToken``, it is + converted to :class:`~mcp.shared.auth.OAuthCredentials` via ``_oauth_token_to_credentials``. + - Write path: credentials produced by discovery/auth are converted via ``_credentials_to_storage`` before + calling ``storage.set_tokens()``. Only ``OAuthCredentials`` are converted into ``OAuthToken``; other + credential types are stored as-is. +- As a result, legacy storage implementations that only support ``get_tokens/set_tokens(OAuthToken)`` can be used + directly with :class:`~mcp.client.auth.multi_protocol.MultiProtocolAuthProvider` without modification. Optionally, + wrap them with :class:`~mcp.client.auth.multi_protocol.OAuthTokenStorageAdapter` to satisfy the multi-protocol + contract explicitly. """ import json import logging -import math +import sys import time from collections.abc import AsyncGenerator from typing import Any, Protocol, cast @@ -32,9 +35,9 @@ from pydantic import ValidationError from mcp.client.auth._oauth_401_flow import oauth_401_flow_generator -from mcp.client.auth.oauth2 import OAuthClientProvider, TokenStorage as OAuth2TokenStorage +from mcp.client.auth.oauth2 import OAuthClientProvider +from mcp.client.auth.oauth2 import TokenStorage as OAuth2TokenStorage from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.client.auth.utils import ( build_protected_resource_metadata_discovery_urls, create_oauth_metadata_request, @@ -46,6 +49,7 @@ extract_scope_from_www_auth, handle_protected_resource_response, ) +from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.shared.auth import ( AuthCredentials, AuthProtocolMetadata, @@ -57,30 +61,32 @@ logger = logging.getLogger(__name__) # Protocol preferences: any protocol without an explicit preference should sort last. -UNSPECIFIED_PROTOCOL_PREFERENCE: float = math.inf +UNSPECIFIED_PROTOCOL_PREFERENCE: int = sys.maxsize class TokenStorage(Protocol): - """ - 凭证存储协议(multi_protocol 契约)。 + """Credential storage interface (multi-protocol contract). + + The multi-protocol contract supports: + - ``get_tokens() -> AuthCredentials | OAuthToken | None`` + - ``set_tokens(AuthCredentials | OAuthToken)`` - 本协议接受 get_tokens() -> AuthCredentials | OAuthToken | None 与 - set_tokens(AuthCredentials | OAuthToken)。仅支持 OAuthToken 的旧存储亦可使用: - MultiProtocolAuthProvider 在 _get_credentials/_discover_and_authenticate 内做 - OAuthToken <-> OAuthCredentials 转换;或使用 OAuthTokenStorageAdapter 包装。 + Legacy storage implementations that only support ``OAuthToken`` are still usable because the provider converts + between ``OAuthToken`` and ``OAuthCredentials`` internally. Alternatively, wrap such storage using + :class:`~mcp.client.auth.multi_protocol.OAuthTokenStorageAdapter`. """ async def get_tokens(self) -> AuthCredentials | OAuthToken | None: - """获取已存储的凭证。""" + """Return stored credentials, if any.""" ... async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: - """存储凭证。""" + """Store credentials.""" ... def _oauth_token_to_credentials(token: OAuthToken) -> OAuthCredentials: - """将 OAuthToken 转为 OAuthCredentials(用于兼容现有存储)。""" + """Convert an OAuthToken into OAuthCredentials (for legacy storage compatibility).""" from mcp.shared.auth_utils import calculate_token_expiry expires_at: int | None = None @@ -98,9 +104,10 @@ def _oauth_token_to_credentials(token: OAuthToken) -> OAuthCredentials: def _credentials_to_storage(credentials: AuthCredentials) -> AuthCredentials | OAuthToken: - """ - 将 AuthCredentials 转为存储可接受格式,便于兼容仅支持 OAuthToken 的旧存储。 - OAuthCredentials 转为 OAuthToken;其他凭证原样返回。 + """Convert AuthCredentials to a storage-friendly shape. + + This exists to support legacy storage implementations that only accept OAuthToken: + OAuthCredentials are converted into OAuthToken; other credential types are returned as-is. """ if isinstance(credentials, OAuthCredentials): expires_in: int | None = None @@ -118,22 +125,19 @@ def _credentials_to_storage(credentials: AuthCredentials) -> AuthCredentials | O class _OAuthTokenOnlyStorage(Protocol): - """仅支持 OAuthToken 的存储契约(供 OAuthTokenStorageAdapter 包装)。""" + """OAuthToken-only storage contract (wrapped by OAuthTokenStorageAdapter).""" - async def get_tokens(self) -> OAuthToken | None: - ... + async def get_tokens(self) -> OAuthToken | None: ... - async def set_tokens(self, tokens: OAuthToken) -> None: - ... + async def set_tokens(self, tokens: OAuthToken) -> None: ... class OAuthTokenStorageAdapter: - """ - 将仅支持 OAuthToken 的 storage 包装为满足 multi_protocol TokenStorage。 + """Adapt an OAuthToken-only storage to the multi-protocol TokenStorage interface. - 取回时把 OAuthToken 转为 OAuthCredentials;写入时把 OAuthCredentials 转为 OAuthToken - 再调用底层 set_tokens。仅 OAuth 凭证会写入底层存储,非 OAuth 凭证(如 APIKeyCredentials) - 不写入。 + - Read path: converts OAuthToken into OAuthCredentials. + - Write path: converts OAuthCredentials into OAuthToken before calling the wrapped storage. + Only OAuth credentials are persisted; non-OAuth credentials (e.g. APIKeyCredentials) are not written. """ def __init__(self, wrapped: _OAuthTokenOnlyStorage) -> None: @@ -146,20 +150,16 @@ async def get_tokens(self) -> AuthCredentials | OAuthToken | None: return _oauth_token_to_credentials(raw) async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: - to_store = ( - _credentials_to_storage(tokens) - if isinstance(tokens, AuthCredentials) - else tokens - ) + to_store = _credentials_to_storage(tokens) if isinstance(tokens, AuthCredentials) else tokens if isinstance(to_store, OAuthToken): await self._wrapped.set_tokens(to_store) class MultiProtocolAuthProvider(httpx.Auth): - """ - 多协议认证提供者。 + """Multi-protocol httpx authentication provider. - 与 httpx 集成,在请求前按所选协议准备认证信息,收到 401/403 时触发发现与认证。 + Integrates with httpx to prepare authentication for requests. On 401/403, it performs discovery and + authentication based on the server's hints and the injected protocol instances. """ requires_response_body = True @@ -187,30 +187,29 @@ def __init__( self._protocols_by_id: dict[str, AuthProtocol] = {} def _initialize(self) -> None: - """根据 protocols 列表构建按 protocol_id 的索引。""" + """Build an index from protocol_id to protocol instances.""" self._protocols_by_id = {p.protocol_id: p for p in self.protocols} self._initialized = True def _get_protocol(self, protocol_id: str) -> AuthProtocol | None: - """按 protocol_id 获取协议实例。""" + """Return a protocol instance by protocol_id.""" return self._protocols_by_id.get(protocol_id) async def _get_credentials(self) -> AuthCredentials | None: - """ - 从存储获取凭证并规范为 AuthCredentials。 + """Load credentials from storage and normalize to AuthCredentials. - 若存储返回 OAuthToken,则转换为 OAuthCredentials 以保持兼容。 + If storage returns OAuthToken, convert it to OAuthCredentials for compatibility. """ raw = await self.storage.get_tokens() if raw is None: return None if isinstance(raw, AuthCredentials): return raw - # raw 此时为 OAuthToken(TokenStorage 返回 AuthCredentials | OAuthToken | None) + # raw is OAuthToken here (TokenStorage returns AuthCredentials | OAuthToken | None) return _oauth_token_to_credentials(raw) def _is_credentials_valid(self, credentials: AuthCredentials | None) -> bool: - """判断凭证是否有效(未过期等),依赖协议实现。""" + """Return True if credentials are valid (e.g. not expired), according to protocol implementation.""" if credentials is None: return False protocol = self._get_protocol(credentials.protocol_id) @@ -228,7 +227,7 @@ async def _ensure_dpop_initialized(self, credentials: AuthCredentials) -> None: await protocol.initialize_dpop() def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: - """为请求添加协议指定的认证信息,包括 DPoP proof(如启用)。""" + """Apply protocol-specific authentication to a request, including DPoP proof if enabled.""" protocol = self._get_protocol(credentials.protocol_id) if protocol is not None: protocol.prepare_request(request, credentials) @@ -252,15 +251,13 @@ def _prepare_request(self, request: httpx.Request, credentials: AuthCredentials) async def _parse_protocols_from_discovery_response( self, response: httpx.Response, prm: ProtectedResourceMetadata | None ) -> list[AuthProtocolMetadata]: - """解析 .well-known/authorization_servers 响应,回退到 PRM。""" + """Parse ``/.well-known/authorization_servers`` response; fall back to PRM if needed.""" if response.status_code == 200: try: content = await response.aread() data = json.loads(content.decode()) raw = data.get("protocols") - protocols_data: list[dict[str, Any]] = ( - cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] - ) + protocols_data: list[dict[str, Any]] = cast(list[dict[str, Any]], raw) if isinstance(raw, list) else [] if protocols_data: return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] except (ValidationError, ValueError, KeyError, TypeError) as e: @@ -269,26 +266,23 @@ async def _parse_protocols_from_discovery_response( return list(prm.mcp_auth_protocols) return [] - async def _handle_403_response( - self, response: httpx.Response, request: httpx.Request - ) -> None: - """处理 403:解析 error/scope 并记录,骨架不做重试。""" + async def _handle_403_response(self, response: httpx.Response, request: httpx.Request) -> None: + """Handle 403 by parsing/logging error and scope (no retries).""" error = extract_field_from_www_auth(response, "error") scope = extract_field_from_www_auth(response, "scope") if error or scope: logger.debug("403 WWW-Authenticate: error=%s scope=%s", error, scope) - async def async_auth_flow( - self, request: httpx.Request - ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """HTTPX 认证流程入口:取凭证、校验、准备请求、发送、处理 401/403 并可选重试。""" + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Entry point for the HTTPX auth flow: load/validate credentials, send request, handle 401/403.""" async with self._lock: if not self._initialized: self._initialize() credentials = await self._get_credentials() if not credentials or not self._is_credentials_valid(credentials): - # 无有效凭证时直接发送请求,依赖 401 响应后再做发现与认证(见下方 401 处理) + # Without valid credentials, send the request first and rely on the 401 handler below + # for discovery and authentication. pass else: await self._ensure_dpop_initialized(credentials) @@ -310,9 +304,7 @@ async def async_auth_flow( # Step 1: PRM discovery (yield) prm: ProtectedResourceMetadata | None = None - prm_urls = build_protected_resource_metadata_discovery_urls( - resource_metadata_url, server_url - ) + prm_urls = build_protected_resource_metadata_discovery_urls(resource_metadata_url, server_url) for url in prm_urls: prm_req = create_oauth_metadata_request(url) prm_resp = yield prm_req @@ -327,61 +319,48 @@ async def async_auth_flow( ) discovery_req = create_oauth_metadata_request(discovery_url) discovery_resp = yield discovery_req - protocols_metadata = await self._parse_protocols_from_discovery_response( - discovery_resp, prm - ) + protocols_metadata = await self._parse_protocols_from_discovery_response(discovery_resp, prm) - available = ( + available: list[str] = ( [m.protocol_id for m in protocols_metadata] if protocols_metadata - else (auth_protocols_header or []) + else (list(auth_protocols_header) if auth_protocols_header is not None else []) ) + if not available and prm is not None and prm.authorization_servers: + # OAuth fallback: if PRM indicates OAuth ASes but unified discovery did not + # return protocol metadata (and the server did not hint via WWW-Authenticate), + # still attempt OAuth2 if injected. + available = ["oauth2"] + logger.debug("No protocols discovered; falling back to oauth2 via PRM authorization_servers") if not available: logger.debug("No available protocols from discovery or WWW-Authenticate") else: # Select protocol candidates based on server hints, but only # attempt protocols that are actually injected as instances. - candidates: list[str] = [] - seen: set[str] = set() - - def _push(pid: str | None) -> None: - if not pid: - return - if pid in seen: - return - seen.add(pid) - candidates.append(pid) - - # Default protocol first (server recommendation) - _push(default_protocol) - # Then order by preferences if provided - if protocol_preferences: - for pid in sorted( - available, - key=lambda p: protocol_preferences.get( - p, UNSPECIFIED_PROTOCOL_PREFERENCE - ), - ): - _push(pid) - # Then remaining in server-provided order - for pid in available: - _push(pid) + candidates_raw: list[str | None] = [default_protocol] + preferences = protocol_preferences + if preferences is not None: + + def preference_key(protocol_id: str) -> int: + return preferences.get(protocol_id, UNSPECIFIED_PROTOCOL_PREFERENCE) + + candidates_raw.extend(sorted(available, key=preference_key)) + candidates_raw.extend(available) + + # De-duplicate while preserving order. + candidates_str = [pid for pid in candidates_raw if pid is not None] + candidates = list(dict.fromkeys(candidates_str)) + + metadata_by_id = {m.protocol_id: m for m in protocols_metadata} if protocols_metadata else {} for selected_id in candidates: protocol = self._get_protocol(selected_id) if protocol is None: - logger.debug( - "Protocol %s not injected as instance; skipping", selected_id - ) + logger.debug("Protocol %s not injected as instance; skipping", selected_id) continue attempted_any = True - protocol_metadata = None - if protocols_metadata: - for m in protocols_metadata: - if m.protocol_id == selected_id: - protocol_metadata = m - break + protocol_metadata = metadata_by_id.get(selected_id) try: if selected_id == "oauth2": @@ -389,9 +368,7 @@ def _push(pid: str | None) -> None: oauth_protocol = protocol provider = OAuthClientProvider( server_url=server_url, - client_metadata=getattr( - oauth_protocol, "_client_metadata" - ), + client_metadata=getattr(oauth_protocol, "_client_metadata"), storage=cast(OAuth2TokenStorage, self.storage), redirect_handler=getattr(oauth_protocol, "_redirect_handler", None), callback_handler=getattr(oauth_protocol, "_callback_handler", None), @@ -423,9 +400,7 @@ def _push(pid: str | None) -> None: http_client=self._http_client, resource_metadata_url=resource_metadata_url, protected_resource_metadata=prm, - scope_from_www_auth=extract_scope_from_www_auth( - original_401_response - ), + scope_from_www_auth=extract_scope_from_www_auth(original_401_response), ) credentials = await protocol.authenticate(context) to_store = _credentials_to_storage(credentials) @@ -435,9 +410,7 @@ def _push(pid: str | None) -> None: break except Exception as e: last_auth_error = e - logger.debug( - "Protocol %s authentication failed: %s", selected_id, e - ) + logger.debug("Protocol %s authentication failed: %s", selected_id, e) continue credentials = await self._get_credentials() diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 7a00eb55a..5d1b18291 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -503,9 +503,10 @@ async def run_authentication( protocol_version: str | None = None, protected_resource_metadata: ProtectedResourceMetadata | None = None, ) -> None: - """ - 使用给定 http_client 执行完整 OAuth 流程(PRM/ASM 发现、scope、注册或 CIMD、授权码+令牌交换), - 与现有 401 分支行为一致。供多协议路径下 OAuth2Protocol 调用。 + """Run the full OAuth flow using the provided http_client. + + This mirrors the existing 401-branch behavior (PRM/OASM discovery, scope selection, registration or CIMD, + authorization code, and token exchange). Used by OAuth2Protocol in the multi-protocol path. """ self.context.protocol_version = protocol_version if protected_resource_metadata is not None: @@ -558,9 +559,7 @@ async def run_authentication( ) if not self.context.client_info: - if should_use_client_metadata_url( - self.context.oauth_metadata, self.context.client_metadata_url - ): + if should_use_client_metadata_url(self.context.oauth_metadata, 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, diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py index 64f089cd4..29acaf17b 100644 --- a/src/mcp/client/auth/protocol.py +++ b/src/mcp/client/auth/protocol.py @@ -1,7 +1,6 @@ -""" -授权协议抽象接口定义。 +"""Auth protocol abstractions. -提供多协议授权支持的统一接口抽象。 +This module defines the shared interfaces used by the multi-protocol authentication system. """ from dataclasses import dataclass @@ -12,23 +11,23 @@ from mcp.shared.auth import AuthCredentials, AuthProtocolMetadata, ProtectedResourceMetadata -# DPoP相关类型占位符(阶段4实现) +# DPoP-related types (implemented as part of the DPoP feature set) class DPoPStorage(Protocol): - """DPoP密钥对存储接口(阶段4实现)""" + """Storage interface for DPoP key pairs.""" async def get_key_pair(self, protocol_id: str) -> Any: ... async def set_key_pair(self, protocol_id: str, key_pair: Any) -> None: ... class DPoPProofGenerator(Protocol): - """DPoP证明生成器接口(阶段4实现)""" + """DPoP proof generator interface.""" def generate_proof(self, method: str, uri: str, credential: str | None = None, nonce: str | None = None) -> str: ... def get_public_key_jwk(self) -> dict[str, Any]: ... class ClientRegistrationResult(Protocol): - """客户端注册结果接口""" + """Client registration result interface.""" client_id: str client_secret: str | None = None @@ -36,16 +35,16 @@ class ClientRegistrationResult(Protocol): @dataclass class AuthContext: - """通用认证上下文""" + """Generic authentication context.""" server_url: str - storage: Any # TokenStorage协议类型 + storage: Any # TokenStorage protocol type protocol_id: str protocol_metadata: AuthProtocolMetadata | None = None current_credentials: AuthCredentials | None = None dpop_storage: DPoPStorage | None = None dpop_enabled: bool = False - # 供 OAuth2Protocol.run_authentication 使用(多协议路径,与 401 分支一致) + # Used by OAuth2Protocol.run_authentication (multi-protocol path; mirrors 401-branch behavior) http_client: httpx.AsyncClient | None = None resource_metadata_url: str | None = None protected_resource_metadata: ProtectedResourceMetadata | None = None @@ -53,39 +52,36 @@ class AuthContext: class AuthProtocol(Protocol): - """授权协议基础接口(所有协议必须实现)""" + """Base auth protocol interface (all protocols must implement this).""" protocol_id: str protocol_version: str async def authenticate(self, context: AuthContext) -> AuthCredentials: - """ - 执行认证流程,获取凭证。 + """Perform authentication and return credentials. Args: - context: 认证上下文 + context: Authentication context. Returns: - 认证凭证 + Authentication credentials. """ ... def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: - """ - 准备HTTP请求,添加认证信息。 + """Prepare an HTTP request by attaching authentication information. Args: - request: HTTP请求对象 - credentials: 认证凭证 + request: HTTP request object. + credentials: Authentication credentials. """ ... def validate_credentials(self, credentials: AuthCredentials) -> bool: - """ - 验证凭证是否有效(未过期等)。 + """Validate credentials (e.g. ensure they are not expired). Args: - credentials: 待验证的凭证 + credentials: Credentials to validate. Returns: True if credentials are valid, False otherwise @@ -98,43 +94,40 @@ async def discover_metadata( prm: ProtectedResourceMetadata | None = None, http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: - """ - 发现协议元数据。 + """Discover protocol metadata. Args: - metadata_url: 元数据URL(可选) - prm: 受保护资源元数据(可选) - http_client: 可选 HTTP 客户端,用于执行 RFC 8414 等网络发现 + metadata_url: Optional metadata URL. + prm: Optional protected resource metadata. + http_client: Optional HTTP client for network discovery (e.g. RFC 8414). Returns: - 协议元数据,如果发现失败则返回None + Protocol metadata, or None if discovery fails. """ ... class ClientRegisterableProtocol(AuthProtocol): - """支持客户端注册的协议扩展接口""" + """Protocol extension for protocols that support client registration.""" async def register_client(self, context: AuthContext) -> ClientRegistrationResult | None: - """ - 注册客户端。 + """Register a client. Args: - context: 认证上下文 + context: Authentication context. Returns: - 客户端注册结果,如果注册失败或不需要注册则返回None + Client registration result, or None if registration is not needed or fails. """ ... @runtime_checkable class DPoPEnabledProtocol(AuthProtocol, Protocol): - """支持DPoP的协议扩展接口(阶段4实现)""" + """Protocol extension for DPoP-capable protocols.""" def supports_dpop(self) -> bool: - """ - 检查协议是否支持DPoP。 + """Return True if this protocol instance supports DPoP. Returns: True if protocol supports DPoP, False otherwise @@ -142,18 +135,13 @@ def supports_dpop(self) -> bool: ... def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: - """ - 获取DPoP证明生成器。 + """Return the DPoP proof generator, if available. Returns: - DPoP证明生成器,如果协议不支持DPoP则返回None + A DPoP proof generator, or None if not supported or not initialized. """ ... async def initialize_dpop(self) -> None: - """ - 初始化DPoP(生成密钥对等)。 - - 仅在协议支持DPoP时调用。 - """ + """Initialize DPoP (e.g. generate key pairs).""" ... diff --git a/src/mcp/client/auth/protocols/__init__.py b/src/mcp/client/auth/protocols/__init__.py index 528d03057..96b3f4bd6 100644 --- a/src/mcp/client/auth/protocols/__init__.py +++ b/src/mcp/client/auth/protocols/__init__.py @@ -1,4 +1,4 @@ -"""协议实现包。""" +"""Protocol implementations package.""" from mcp.client.auth.protocols.oauth2 import OAuth2Protocol diff --git a/src/mcp/client/auth/protocols/oauth2.py b/src/mcp/client/auth/protocols/oauth2.py index aef61bc46..60c017abf 100644 --- a/src/mcp/client/auth/protocols/oauth2.py +++ b/src/mcp/client/auth/protocols/oauth2.py @@ -1,10 +1,10 @@ -""" -OAuth 2.0 协议薄适配层。 +"""OAuth 2.0 protocol thin adapter. + +This module intentionally does not re-implement OAuth discovery/registration/authorization/token exchange. +``authenticate(context)`` constructs an OAuthClientProvider, populates context, and delegates to +``provider.run_authentication(context.http_client, ...)``, returning OAuthCredentials. -不迁移 OAuth 发现/注册/授权码/令牌交换逻辑到此文件; -authenticate(context) 构造 OAuthClientProvider、填充上下文后调用 -provider.run_authentication(context.http_client, ...),返回 OAuthCredentials。 -discover_metadata 在提供 http_client 时执行 RFC 8414 授权服务器元数据发现。 +``discover_metadata`` performs RFC 8414 authorization server metadata discovery when an http_client is provided. """ import logging @@ -43,7 +43,7 @@ def _oauth_metadata_to_protocol_metadata(asm: OAuthMetadata) -> AuthProtocolMetadata: - """将 RFC 8414 OAuth 授权服务器元数据转换为 AuthProtocolMetadata。""" + """Convert RFC 8414 OAuth authorization server metadata to AuthProtocolMetadata.""" endpoints: dict[str, AnyHttpUrl] = { "authorization_endpoint": asm.authorization_endpoint, "token_endpoint": asm.token_endpoint, @@ -55,7 +55,7 @@ def _oauth_metadata_to_protocol_metadata(asm: OAuthMetadata) -> AuthProtocolMeta endpoints["revocation_endpoint"] = asm.revocation_endpoint if asm.introspection_endpoint is not None: endpoints["introspection_endpoint"] = asm.introspection_endpoint - + return AuthProtocolMetadata( protocol_id="oauth2", protocol_version="2.0", @@ -68,7 +68,7 @@ def _oauth_metadata_to_protocol_metadata(asm: OAuthMetadata) -> AuthProtocolMeta def _token_to_oauth_credentials(token: OAuthToken) -> OAuthCredentials: - """将 OAuthToken 转为 OAuthCredentials。""" + """Convert OAuthToken into OAuthCredentials.""" from mcp.shared.auth_utils import calculate_token_expiry expires_at: int | None = None @@ -88,11 +88,11 @@ def _token_to_oauth_credentials(token: OAuthToken) -> OAuthCredentials: class OAuth2Protocol: - """ - OAuth 2.0 协议薄适配层。 + """OAuth 2.0 protocol thin adapter. - 实现 AuthProtocol 和 DPoPEnabledProtocol,authenticate 委托 OAuthClientProvider.run_authentication, - 不重复实现 OAuth 流程。DPoP 支持通过 dpop_enabled 配置启用。 + Implements AuthProtocol and DPoPEnabledProtocol. ``authenticate`` delegates to + OAuthClientProvider.run_authentication instead of duplicating OAuth flow logic. DPoP can be enabled via + ``dpop_enabled`` configuration. """ protocol_id: str = "oauth2" @@ -123,8 +123,8 @@ def __init__( self._dpop_generator: DPoPProofGeneratorImpl | None = None async def authenticate(self, context: AuthContext) -> AuthCredentials: - """从 AuthContext 组装 OAuth 上下文,委托 OAuthClientProvider.run_authentication,返回 OAuthCredentials。 - + """Assemble OAuth context from AuthContext and delegate to OAuthClientProvider.run_authentication. + Note: Uses a fresh httpx client without auth for OAuth flow to avoid lock deadlock when called from within MultiProtocolAuthProvider.async_auth_flow. """ @@ -140,9 +140,7 @@ async def authenticate(self, context: AuthContext) -> AuthCredentials: ) protocol_version: str | None = None if context.protocol_metadata is not None: - protocol_version = getattr( - context.protocol_metadata, "protocol_version", None - ) + protocol_version = getattr(context.protocol_metadata, "protocol_version", None) # Use a fresh client without auth for OAuth discovery/registration/token exchange # to avoid lock deadlock when called from async_auth_flow async with httpx.AsyncClient(follow_redirects=True) as oauth_client: @@ -158,12 +156,12 @@ async def authenticate(self, context: AuthContext) -> AuthCredentials: return _token_to_oauth_credentials(provider.context.current_tokens) def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: - """为请求添加 Bearer 认证头。""" + """Attach Bearer authorization header.""" if isinstance(credentials, OAuthCredentials) and credentials.access_token: request.headers["Authorization"] = f"Bearer {credentials.access_token}" def validate_credentials(self, credentials: AuthCredentials) -> bool: - """验证 OAuth 凭证是否有效(未过期等)。""" + """Validate OAuth credentials (e.g. not expired).""" if not isinstance(credentials, OAuthCredentials): return False if not credentials.access_token: @@ -178,12 +176,11 @@ async def discover_metadata( prm: ProtectedResourceMetadata | None = None, http_client: httpx.AsyncClient | None = None, ) -> AuthProtocolMetadata | None: - """ - 发现 OAuth 2.0 协议元数据(RFC 8414)。 + """Discover OAuth 2.0 protocol metadata (RFC 8414). - 若 prm 中已有 oauth2 的 mcp_auth_protocols 条目则直接返回; - 若提供 http_client 且存在 metadata_url 或 prm.authorization_servers, - 则按 RFC 8414 请求授权服务器元数据并转换为 AuthProtocolMetadata。 + If PRM already contains an oauth2 entry in ``mcp_auth_protocols``, return it directly. Otherwise, when an + http_client is provided and we have metadata_url or prm.authorization_servers, request RFC 8414 metadata + and convert it into AuthProtocolMetadata. """ if prm is not None and prm.mcp_auth_protocols: for m in prm.mcp_auth_protocols: @@ -233,9 +230,7 @@ async def initialize_dpop(self) -> None: if not self._dpop_enabled: return if self._dpop_key_pair is None: - self._dpop_key_pair = DPoPKeyPair.generate( - self._dpop_algorithm, rsa_key_size=self._dpop_rsa_key_size - ) + self._dpop_key_pair = DPoPKeyPair.generate(self._dpop_algorithm, rsa_key_size=self._dpop_rsa_key_size) self._dpop_generator = DPoPProofGeneratorImpl(self._dpop_key_pair) def get_dpop_public_key_jwk(self) -> dict[str, Any] | None: diff --git a/src/mcp/client/auth/registry.py b/src/mcp/client/auth/registry.py index e3dc6d185..04a040c3d 100644 --- a/src/mcp/client/auth/registry.py +++ b/src/mcp/client/auth/registry.py @@ -1,42 +1,39 @@ -""" -协议注册表。 +"""Auth protocol registry. -提供多协议授权实现的注册与选择逻辑。 +Provides registration and selection logic for multi-protocol authentication. """ from mcp.client.auth.protocol import AuthProtocol class AuthProtocolRegistry: - """ - 授权协议注册表。 + """Registry for auth protocol implementations. - 用于注册和获取协议实现类,并根据服务器声明的可用协议、默认协议及优先级选择协议。 + Stores protocol implementation classes and selects a protocol based on server-declared availability, defaults, + and preferences. """ _protocols: dict[str, type[AuthProtocol]] = {} @classmethod def register(cls, protocol_id: str, protocol_class: type[AuthProtocol]) -> None: - """ - 注册协议实现。 + """Register a protocol implementation. Args: - protocol_id: 协议标识(如 oauth2、api_key) - protocol_class: 实现 AuthProtocol 的类(非实例) + protocol_id: Protocol identifier (e.g. "oauth2", "api_key"). + protocol_class: Class implementing AuthProtocol (not an instance). """ cls._protocols[protocol_id] = protocol_class @classmethod def get_protocol_class(cls, protocol_id: str) -> type[AuthProtocol] | None: - """ - 获取协议实现类。 + """Return a registered protocol class by protocol_id. Args: - protocol_id: 协议标识 + protocol_id: Protocol identifier. Returns: - 协议类,未注册时返回 None + Protocol class, or None if not registered. """ return cls._protocols.get(protocol_id) @@ -47,22 +44,21 @@ def select_protocol( default_protocol: str | None = None, preferences: dict[str, int] | None = None, ) -> str | None: - """ - 从服务器声明的可用协议中选出一个客户端支持的协议。 + """Select one protocol that the client supports from server-declared available protocols. - 选择顺序: - 1. 过滤出客户端已注册的协议 - 2. 若存在默认协议且客户端支持,则优先返回默认协议 - 3. 若有优先级映射,按优先级数值升序排序后取第一个 - 4. 否则返回第一个支持的协议 + Selection order: + 1. Filter protocols to those registered in the client. + 2. If a default protocol is provided and supported, return it. + 3. If a preference map is provided, sort by ascending preference value and pick the first. + 4. Otherwise return the first supported protocol. Args: - available_protocols: 服务器声明的可用协议 ID 列表 - default_protocol: 服务器推荐的默认协议 ID(可选) - preferences: 协议优先级映射,数值越小优先级越高(可选) + available_protocols: Server-declared available protocol IDs. + default_protocol: Optional server-recommended default protocol ID. + preferences: Optional protocol preference mapping (smaller value means higher priority). Returns: - 选中的协议 ID,若无交集则返回 None + Selected protocol ID, or None if there is no overlap. """ supported = [p for p in available_protocols if p in cls._protocols] if not supported: @@ -78,10 +74,9 @@ def select_protocol( @classmethod def list_registered(cls) -> list[str]: - """ - 返回已注册的协议 ID 列表(便于测试或调试)。 + """Return registered protocol IDs (useful for tests/debugging). Returns: - 已注册的 protocol_id 列表 + List of registered protocol IDs. """ return list(cls._protocols.keys()) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index bae906bc4..bb7f8a658 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -43,7 +43,7 @@ def extract_field_from_www_auth(response: Response, field_name: str, auth_scheme # If auth_scheme is specified, extract only from that scheme's parameters if auth_scheme: # Pattern to match the specified auth scheme and its parameters - scheme_pattern = rf'{re.escape(auth_scheme)}\s+([^,]+(?:,\s*[^,]+)*)' + scheme_pattern = rf"{re.escape(auth_scheme)}\s+([^,]+(?:,\s*[^,]+)*)" scheme_match = re.search(scheme_pattern, www_auth_header, re.IGNORECASE) if not scheme_match: return None @@ -86,8 +86,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None: def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None: - """ - Extract auth_protocols field from WWW-Authenticate header (MCP extension). + """Extract auth_protocols field from WWW-Authenticate header (MCP extension). Returns: List of protocol IDs if found in WWW-Authenticate header, None otherwise @@ -99,8 +98,7 @@ def extract_auth_protocols_from_www_auth(response: Response) -> list[str] | None def extract_default_protocol_from_www_auth(response: Response) -> str | None: - """ - Extract default_protocol field from WWW-Authenticate header (MCP extension). + """Extract default_protocol field from WWW-Authenticate header (MCP extension). Returns: Default protocol ID if found in WWW-Authenticate header, None otherwise @@ -109,8 +107,7 @@ def extract_default_protocol_from_www_auth(response: Response) -> str | None: def extract_protocol_preferences_from_www_auth(response: Response) -> dict[str, int] | None: - """ - Extract protocol_preferences field from WWW-Authenticate header (MCP extension). + """Extract protocol_preferences field from WWW-Authenticate header (MCP extension). Format: "protocol1:priority1,protocol2:priority2" @@ -168,8 +165,7 @@ async def discover_authorization_servers( http_client: AsyncClient, prm: ProtectedResourceMetadata | None = None, ) -> list[AuthProtocolMetadata]: - """ - Discover supported auth protocols (unified discovery with PRM fallback). + """Discover supported auth protocols (unified discovery with PRM fallback). 1. Tries the unified capability discovery endpoint `/.well-known/authorization_servers` (path relative to resource_url). diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ebf2ef842..f1c10dca9 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -157,18 +157,18 @@ class OAuthMetadata(BaseModel): class AuthProtocolMetadata(BaseModel): - """单个授权协议的元数据(MCP扩展)""" + """Metadata for a single auth protocol (MCP extension).""" protocol_id: str = Field(..., pattern=r"^[a-z0-9_]+$") protocol_version: str metadata_url: AnyHttpUrl | None = None endpoints: dict[str, AnyHttpUrl] = Field(default_factory=dict) capabilities: list[str] = Field(default_factory=list) - # OAuth特定字段(可选) + # OAuth-specific fields (optional) client_auth_methods: list[str] | None = None grant_types: list[str] | None = None scopes_supported: list[str] | None = None - # DPoP支持(协议无关) + # DPoP support (protocol-agnostic) dpop_signing_alg_values_supported: list[str] | None = None dpop_bound_credentials_required: bool | None = None additional_params: dict[str, Any] = Field(default_factory=dict) @@ -195,7 +195,7 @@ class ProtectedResourceMetadata(BaseModel): dpop_signing_alg_values_supported: list[str] | None = None # dpop_bound_access_tokens_required default is False, but ommited here for clarity dpop_bound_access_tokens_required: bool | None = None - # MCP扩展字段(多协议支持) + # MCP extension fields (multi-protocol support) mcp_auth_protocols: list["AuthProtocolMetadata"] | None = None mcp_default_auth_protocol: str | None = None - mcp_auth_protocol_preferences: dict[str, int] | None = None \ No newline at end of file + mcp_auth_protocol_preferences: dict[str, int] | None = None From 187c433299a1fa44babc13c25a193dcc3caebdb6 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:15:24 +0800 Subject: [PATCH 50/64] chore(dpop): tidy client implementation and tests --- src/mcp/client/auth/dpop.py | 7 ++----- tests/client/auth/test_dpop.py | 4 +--- tests/client/auth/test_dpop_integration.py | 8 ++------ 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/mcp/client/auth/dpop.py b/src/mcp/client/auth/dpop.py index 5b9a2e26d..3537252ec 100644 --- a/src/mcp/client/auth/dpop.py +++ b/src/mcp/client/auth/dpop.py @@ -1,5 +1,4 @@ -""" -DPoP (Demonstrating Proof-of-Possession) client implementation. +"""DPoP (Demonstrating Proof-of-Possession) client implementation. RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP). Provides DPoPKeyPair, DPoPProofGenerator, DPoPStorage for generating DPoP proof JWTs. @@ -94,9 +93,7 @@ def generate( key: EllipticCurvePrivateKey | RSAPrivateKey = ec_generate(SECP256R1()) elif algorithm == "RS256": if rsa_key_size < RSA_KEY_SIZE_DEFAULT: - raise ValueError( - f"RSA key size must be at least {RSA_KEY_SIZE_DEFAULT} bits, got {rsa_key_size}" - ) + raise ValueError(f"RSA key size must be at least {RSA_KEY_SIZE_DEFAULT} bits, got {rsa_key_size}") key = rsa_generate(public_exponent=_RSA_PUBLIC_EXPONENT, key_size=rsa_key_size) else: raise ValueError(f"Unsupported algorithm: {algorithm}") diff --git a/tests/client/auth/test_dpop.py b/tests/client/auth/test_dpop.py index 043053e12..6452de83d 100644 --- a/tests/client/auth/test_dpop.py +++ b/tests/client/auth/test_dpop.py @@ -46,9 +46,7 @@ def test_dpop_proof_includes_ath_when_credential_provided() -> None: gen = DPoPProofGeneratorImpl(pair) proof = gen.generate_proof("GET", "https://rs.example/res", credential="my-token") decoded = jwt.decode(proof, options={"verify_signature": False}) - expected_ath = base64.urlsafe_b64encode( - hashlib.sha256(b"my-token").digest() - ).decode().rstrip("=") + expected_ath = base64.urlsafe_b64encode(hashlib.sha256(b"my-token").digest()).decode().rstrip("=") assert decoded["ath"] == expected_ath diff --git a/tests/client/auth/test_dpop_integration.py b/tests/client/auth/test_dpop_integration.py index 5f7f85a25..8aa964c4f 100644 --- a/tests/client/auth/test_dpop_integration.py +++ b/tests/client/auth/test_dpop_integration.py @@ -49,9 +49,7 @@ async def test_oauth2_protocol_initialize_dpop(client_metadata: OAuthClientMetad @pytest.mark.anyio async def test_oauth2_protocol_initialize_dpop_rs256(client_metadata: OAuthClientMetadata) -> None: """initialize_dpop should support RS256 algorithm.""" - protocol = OAuth2Protocol( - client_metadata=client_metadata, dpop_enabled=True, dpop_algorithm="RS256" - ) + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True, dpop_algorithm="RS256") await protocol.initialize_dpop() jwk = protocol.get_dpop_public_key_jwk() @@ -105,9 +103,7 @@ async def test_dpop_proof_generation(client_metadata: OAuthClientMetadata) -> No assert len(proof) > 0 # Proof with access token binding - proof_with_ath = generator.generate_proof( - "GET", "https://api.example.com/resource", credential="access-token-123" - ) + proof_with_ath = generator.generate_proof("GET", "https://api.example.com/resource", credential="access-token-123") assert proof_with_ath is not None assert proof_with_ath != proof From de0241cbaca0453a6648c8cc2f6d89b48a671e17 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:15:38 +0800 Subject: [PATCH 51/64] test(auth): tidy multiprotocol client test docs --- tests/client/auth/test_oauth2_protocol.py | 18 +++++-- tests/client/test_auth_integration_phase2.py | 11 ++-- tests/client/test_multi_protocol_provider.py | 54 +++++++++++++++++--- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index e8515fbd1..4ccab7e44 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -1,4 +1,11 @@ -"""单元测试:OAuth2Protocol 薄适配层(authenticate 委托 run_authentication、prepare_request、validate_credentials、discover_metadata)。""" +"""Unit tests for OAuth2Protocol thin adapter. + +Covers: +- authenticate delegation to run_authentication +- prepare_request +- validate_credentials +- discover_metadata +""" import httpx import pytest @@ -111,7 +118,7 @@ def test_validate_credentials_returns_false_when_no_token( async def test_discover_metadata_returns_none_without_http_client( oauth2_protocol: OAuth2Protocol, ) -> None: - """无 http_client 且无 prm 或 prm 无 oauth2 时,不发起网络请求,返回 None。""" + """Return None without network when no http_client and no oauth2 entry in PRM.""" result = await oauth2_protocol.discover_metadata( metadata_url="https://example.com/.well-known/oauth-authorization-server", prm=None, @@ -123,7 +130,7 @@ async def test_discover_metadata_returns_none_without_http_client( async def test_discover_metadata_from_prm_returns_oauth2_entry( oauth2_protocol: OAuth2Protocol, ) -> None: - """当 prm.mcp_auth_protocols 含 oauth2 时,直接返回该条目,无需 http_client。""" + """Return oauth2 entry directly from prm.mcp_auth_protocols without requiring http_client.""" from pydantic import AnyHttpUrl oauth2_meta = AuthProtocolMetadata( @@ -154,7 +161,7 @@ async def test_authenticate_creates_own_http_client( client_metadata: OAuthClientMetadata, ) -> None: """OAuth2Protocol.authenticate creates its own httpx client, so context.http_client can be None. - + This tests that the method doesn't crash when http_client is None. It will still fail during OAuth discovery (no server running), but that's expected. """ @@ -173,6 +180,7 @@ async def test_authenticate_creates_own_http_client( # Now authenticate creates its own client, so it won't raise ValueError for http_client=None # It will fail during OAuth discovery since there's no server, which is expected from mcp.client.auth.exceptions import OAuthFlowError + with pytest.raises(OAuthFlowError, match="Could not discover"): await oauth2_protocol.authenticate(context) @@ -182,7 +190,7 @@ async def test_authenticate_delegates_to_run_authentication_and_returns_oauth_cr oauth2_protocol: OAuth2Protocol, client_metadata: OAuthClientMetadata, ) -> None: - """authenticate(context) 调用 provider.run_authentication 并从 current_tokens 转为 OAuthCredentials。""" + """authenticate(context) delegates to provider.run_authentication and converts current_tokens to OAuthCredentials.""" from unittest.mock import AsyncMock, MagicMock, patch mock_storage = MagicMock() diff --git a/tests/client/test_auth_integration_phase2.py b/tests/client/test_auth_integration_phase2.py index 433397f14..ccf8c1a97 100644 --- a/tests/client/test_auth_integration_phase2.py +++ b/tests/client/test_auth_integration_phase2.py @@ -1,5 +1,4 @@ -""" -Phase2 integration tests: unified discovery endpoint and 401 WWW-Authenticate auth_protocols extension. +"""Phase2 integration tests: unified discovery endpoint and 401 WWW-Authenticate auth_protocols extension. - Client requests /.well-known/authorization_servers and gets protocol list. - Server 401 header contains auth_protocols/default_protocol/protocol_preferences and client parses them. @@ -22,7 +21,7 @@ @pytest.mark.anyio async def test_client_discovers_protocols_via_unified_endpoint_integration() -> None: - """Integration: app serves /.well-known/authorization_servers, client discover_authorization_servers returns protocols.""" + """Integration: client discovers protocols via unified endpoint.""" routes = create_authorization_servers_discovery_routes( protocols=[ AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0"), @@ -45,7 +44,7 @@ async def test_client_discovers_protocols_via_unified_endpoint_integration() -> @pytest.mark.anyio async def test_client_parses_401_www_authenticate_auth_protocols_extension() -> None: - """401 WWW-Authenticate with auth_protocols, default_protocol, protocol_preferences; client extractors return correct values.""" + """401 header extension fields are parsed correctly.""" www_auth = ( 'Bearer auth_protocols="oauth2 api_key", default_protocol="oauth2", protocol_preferences="oauth2:1,api_key:2"' ) @@ -69,7 +68,9 @@ async def test_client_parses_401_without_auth_protocols_extension_returns_none() """401 WWW-Authenticate without auth_protocols extension; extractors return None.""" response = httpx.Response( 401, - headers={"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'}, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + }, request=httpx.Request("GET", "https://api.example.com/test"), ) assert extract_auth_protocols_from_www_auth(response) is None diff --git a/tests/client/test_multi_protocol_provider.py b/tests/client/test_multi_protocol_provider.py index 2206138c4..18d6d4220 100644 --- a/tests/client/test_multi_protocol_provider.py +++ b/tests/client/test_multi_protocol_provider.py @@ -242,7 +242,11 @@ def handler(request: httpx.Request) -> httpx.Response: "resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"], "mcp_auth_protocols": [ - {"protocol_id": "oauth2", "protocol_version": "2.0", "metadata_url": "https://as.example/.well-known/oauth-authorization-server"}, + { + "protocol_id": "oauth2", + "protocol_version": "2.0", + "metadata_url": "https://as.example/.well-known/oauth-authorization-server", + }, {"protocol_id": "api_key", "protocol_version": "1.0"}, {"protocol_id": "mutual_tls", "protocol_version": "1.0"}, ], @@ -256,7 +260,15 @@ def handler(request: httpx.Request) -> httpx.Response: if request.headers.get("x-api-key") == api_key: return httpx.Response( 200, - json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "rs", "version": "1.0"}}}, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": {"name": "rs", "version": "1.0"}, + }, + }, ) # 401 with multi-protocol hints www = ( @@ -281,7 +293,19 @@ def handler(request: httpx.Request) -> httpx.Response: http_client=client, ) client.auth = provider - r = await client.post("https://rs.example/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "1.0"}}}) + r = await client.post( + "https://rs.example/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "t", "version": "1.0"}, + }, + }, + ) assert r.status_code == 200 # Must have retried POST /mcp with X-API-Key @@ -292,7 +316,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_401_flow_does_not_leak_discovery_response_when_no_protocols_injected() -> None: - """If no protocol instance is available, final response should correspond to original request (401), not discovery 404.""" + """Final response should match original request (401), not discovery 404.""" seen: list[tuple[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -302,7 +326,11 @@ def handler(request: httpx.Request) -> httpx.Response: "resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"], "mcp_auth_protocols": [ - {"protocol_id": "oauth2", "protocol_version": "2.0", "metadata_url": "https://as.example/.well-known/oauth-authorization-server"}, + { + "protocol_id": "oauth2", + "protocol_version": "2.0", + "metadata_url": "https://as.example/.well-known/oauth-authorization-server", + }, {"protocol_id": "api_key", "protocol_version": "1.0"}, ], } @@ -330,7 +358,19 @@ def handler(request: httpx.Request) -> httpx.Response: http_client=client, ) client.auth = provider - r = await client.post("https://rs.example/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "1.0"}}}) + r = await client.post( + "https://rs.example/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "t", "version": "1.0"}, + }, + }, + ) assert r.status_code == 401 # We should have attempted discovery, but final response must not be the discovery 404. @@ -396,7 +436,7 @@ async def test_oauth_token_storage_adapter_set_tokens_stores_oauth_token_when_gi @pytest.mark.anyio async def test_get_credentials_returns_oauth_credentials_when_storage_returns_oauth_token() -> None: - """MultiProtocolAuthProvider._get_credentials converts OAuthToken from storage to OAuthCredentials (dual contract).""" + """_get_credentials converts OAuthToken from storage to OAuthCredentials.""" raw = OAuthToken( access_token="stored_at", token_type="Bearer", From 4348a2cb529a0f8cdbd4e6708f5a1aadab16403d Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:16:20 +0800 Subject: [PATCH 52/64] feat(server): add FastMCP compatibility wrapper for examples --- src/mcp/server/fastmcp/__init__.py | 3 ++ src/mcp/server/fastmcp/server.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/mcp/server/fastmcp/__init__.py create mode 100644 src/mcp/server/fastmcp/server.py diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py new file mode 100644 index 000000000..26e3d6b26 --- /dev/null +++ b/src/mcp/server/fastmcp/__init__.py @@ -0,0 +1,3 @@ +from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp + +__all__ = ["FastMCP", "StreamableHTTPASGIApp"] diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py new file mode 100644 index 000000000..8100795f3 --- /dev/null +++ b/src/mcp/server/fastmcp/server.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.mcpserver.server import MCPServer +from mcp.server.streamable_http_manager import StreamableHTTPASGIApp as _StreamableHTTPASGIApp + +StreamableHTTPASGIApp = _StreamableHTTPASGIApp + + +class FastMCP: + """Small compatibility wrapper used by examples. + + This repository's public server implementation is `mcp.server.mcpserver.server.MCPServer`. + Some examples use a `FastMCP` naming convention and expect an attribute called `_mcp_server` + that can be passed into `StreamableHTTPSessionManager`. + """ + + def __init__( + self, + *, + name: str, + instructions: str = "", + host: str | None = None, + port: int | None = None, + auth: Any = None, + **kwargs: Any, + ) -> None: + # host/port are kept for the example interface; `MCPServer` itself does not need them. + self.host = host + self.port = port + + self._server = MCPServer( + name=name, + instructions=instructions, + auth=auth, + **kwargs, + ) + + # Examples expect this to be the low-level Server instance. + self._mcp_server = getattr(self._server, "_lowlevel_server") + + def tool(self, *args: Any, **kwargs: Any): + return self._server.tool(*args, **kwargs) From 8855822c2f03c68c5e1b074d92a243fce8344eb0 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:16:34 +0800 Subject: [PATCH 53/64] chore(examples): update multiprotocol auth examples --- .../mcp_simple_auth_multiprotocol_client/main.py | 12 ++++-------- .../mcp_simple_auth_multiprotocol/__main__.py | 2 +- .../mcp_simple_auth_multiprotocol/multiprotocol.py | 10 +++------- .../mcp_simple_auth_multiprotocol/server.py | 3 +-- .../mcp_simple_auth_multiprotocol/token_verifier.py | 8 ++------ .../__init__.py | 1 - .../__main__.py | 3 +-- .../multiprotocol.py | 5 +---- .../server.py | 8 ++++---- .../token_verifier.py | 9 ++------- .../__init__.py | 1 - .../__main__.py | 3 +-- .../multiprotocol.py | 5 +---- .../server.py | 5 +---- .../token_verifier.py | 9 ++------- .../__init__.py | 1 - .../__main__.py | 3 +-- .../multiprotocol.py | 8 ++------ .../mcp_simple_auth_multiprotocol_prm_only/server.py | 4 +--- .../token_verifier.py | 9 ++------- .../__init__.py | 1 - .../__main__.py | 3 +-- .../multiprotocol.py | 5 +---- .../server.py | 4 +--- .../token_verifier.py | 9 ++------- .../mcp_simple_auth/simple_auth_provider.py | 1 + 26 files changed, 36 insertions(+), 96 deletions(-) diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py index 156d1de96..f289485bd 100644 --- a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -30,7 +30,7 @@ class InMemoryStorage(TokenStorage): """In-memory credential storage supporting both AuthCredentials and OAuthToken. - + Also implements get_client_info/set_client_info for OAuth client registration storage. """ @@ -261,22 +261,18 @@ async def redirect_handler(url: str) -> None: async with streamable_http_client( url=self.server_url, http_client=http_client, - ) as (read_stream, write_stream, get_session_id): - await self._run_session(read_stream, write_stream, get_session_id) + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream) finally: if callback_server: callback_server.stop() - async def _run_session(self, read_stream: Any, write_stream: Any, get_session_id: Any) -> None: + async def _run_session(self, read_stream: Any, write_stream: Any) -> None: print("Initializing MCP session...") async with ClientSession(read_stream, write_stream) as session: self.session = session await session.initialize() print("Session initialized.") - if get_session_id: - sid = get_session_id() - if sid: - print(f"Session ID: {sid}") await self._interactive_loop() async def list_tools(self) -> None: diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py index 00308da6d..a91db2b20 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/__main__.py @@ -2,6 +2,6 @@ import sys -from mcp_simple_auth_multiprotocol.server import main +from .server import main sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py index 3ddf868b5..28e45432a 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/multiprotocol.py @@ -21,8 +21,7 @@ class MutualTLSVerifier: - """ - Placeholder verifier for Mutual TLS. + """Placeholder verifier for Mutual TLS. Does not validate client certificates; returns None. Real mTLS validation would inspect the TLS connection for client certificate and verify it. @@ -59,9 +58,7 @@ def build_multiprotocol_backend( scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend( - verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] - ) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) dpop_verifier: DPoPProofVerifier | None = None if dpop_enabled: @@ -71,8 +68,7 @@ def build_multiprotocol_backend( class MultiProtocolAuthBackendAdapter(AuthenticationBackend): - """ - Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend. + """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend. Converts AccessToken from backend.verify() into (AuthCredentials, AuthenticatedUser). Optionally verifies DPoP proofs when dpop_verifier is provided. diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py index bdc0d311e..3c545a823 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with multi-protocol auth (OAuth, API Key, Mutual TLS placeholder). +"""MCP Resource Server with multi-protocol auth (OAuth, API Key, Mutual TLS placeholder). Uses MultiProtocolAuthBackend, PRM with auth_protocols, and /.well-known/authorization_servers. """ diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py index c57a81135..2fbfb6853 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py @@ -27,9 +27,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" import httpx - if not self.introspection_endpoint.startswith( - ("https://", "http://localhost", "http://127.0.0.1") - ): + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None @@ -75,6 +73,4 @@ def _validate_resource(self, token_data: dict[str, Any]) -> bool: def _is_valid_resource(self, resource: str) -> bool: if not self.resource_url: return False - return check_resource_allowed( - requested_resource=self.resource_url, configured_resource=resource - ) + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py index 9873aa3a7..d308eba98 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__init__.py @@ -1,2 +1 @@ """MCP Resource Server (multiprotocol, OAuth-fallback discovery variant).""" - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py index 76d5ac5eb..862a60544 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/__main__.py @@ -2,7 +2,6 @@ import sys -from mcp_simple_auth_multiprotocol_oauth_fallback.server import main +from .server import main sys.exit(main()) # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py index 32416fc2f..7b742c62b 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py @@ -44,9 +44,7 @@ def build_multiprotocol_backend( scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend( - verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] - ) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) dpop_verifier: DPoPProofVerifier | None = None if dpop_enabled: @@ -100,4 +98,3 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, Aut AuthCredentials(result.scopes or []), AuthenticatedUser(result), ) - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py index ba5d64b8c..b2dc32831 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with multi-protocol auth (OAuth-fallback discovery variant). +"""MCP Resource Server with multi-protocol auth (OAuth-fallback discovery variant). This variant: - PRM does NOT include mcp_auth_protocols (only authorization_servers) @@ -105,7 +104,9 @@ def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> St fastmcp = FastMCP( name="MCP Resource Server (multiprotocol, OAuth-fallback discovery)", - instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (OAuth-fallback discovery)", + instructions=( + "Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (OAuth-fallback discovery)" + ), host=settings.host, port=settings.port, auth=None, @@ -247,4 +248,3 @@ def main( if __name__ == "__main__": main() # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py index b02bccf3e..e34830263 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py @@ -27,9 +27,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" import httpx - if not self.introspection_endpoint.startswith( - ("https://", "http://localhost", "http://127.0.0.1") - ): + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None @@ -75,7 +73,4 @@ def _validate_resource(self, token_data: dict[str, Any]) -> bool: def _is_valid_resource(self, resource: str) -> bool: if not self.resource_url: return False - return check_resource_allowed( - requested_resource=self.resource_url, configured_resource=resource - ) - + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py index dadaff77d..0227b0d81 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__init__.py @@ -1,2 +1 @@ """MCP Resource Server (multiprotocol, path-only unified discovery variant).""" - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py index dff14da53..067b75aa3 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/__main__.py @@ -2,7 +2,6 @@ import sys -from mcp_simple_auth_multiprotocol_path_only.server import main +from .server import main sys.exit(main()) # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py index 7b88ee0a1..0cc9bc11d 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py @@ -44,9 +44,7 @@ def build_multiprotocol_backend( scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend( - verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] - ) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) dpop_verifier: DPoPProofVerifier | None = None if dpop_enabled: @@ -100,4 +98,3 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, Aut AuthCredentials(result.scopes or []), AuthenticatedUser(result), ) - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py index 5d5bbd80e..2fc7bac37 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with multi-protocol auth (path-only unified discovery variant). +"""MCP Resource Server with multi-protocol auth (path-only unified discovery variant). This variant: - PRM does NOT include mcp_auth_protocols (only authorization_servers) @@ -26,7 +25,6 @@ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from mcp.server.auth.routes import ( build_resource_metadata_url, - create_authorization_servers_discovery_routes, create_protected_resource_routes, ) from mcp.server.auth.settings import AuthSettings @@ -260,4 +258,3 @@ def main( if __name__ == "__main__": main() # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py index e680c64df..9191ef45c 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py @@ -27,9 +27,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" import httpx - if not self.introspection_endpoint.startswith( - ("https://", "http://localhost", "http://127.0.0.1") - ): + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None @@ -75,7 +73,4 @@ def _validate_resource(self, token_data: dict[str, Any]) -> bool: def _is_valid_resource(self, resource: str) -> bool: if not self.resource_url: return False - return check_resource_allowed( - requested_resource=self.resource_url, configured_resource=resource - ) - + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py index b2c4f3b6c..2dc2a45d8 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__init__.py @@ -1,2 +1 @@ """MCP Resource Server (multiprotocol, PRM-only discovery variant).""" - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py index 0ce22b982..8600e454e 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/__main__.py @@ -2,7 +2,6 @@ import sys -from mcp_simple_auth_multiprotocol_prm_only.server import main +from .server import main sys.exit(main()) # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py index 64b15ac7c..c983eb397 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py @@ -21,8 +21,7 @@ class MutualTLSVerifier: - """ - Placeholder verifier for Mutual TLS. + """Placeholder verifier for Mutual TLS. Does not validate client certificates; returns None. Real mTLS validation would inspect the TLS connection for client certificate and verify it. @@ -49,9 +48,7 @@ def build_multiprotocol_backend( scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend( - verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] - ) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) dpop_verifier: DPoPProofVerifier | None = None if dpop_enabled: @@ -105,4 +102,3 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, Aut AuthCredentials(result.scopes or []), AuthenticatedUser(result), ) - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py index cf5e65445..c6ccf0440 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with multi-protocol auth (PRM-only discovery variant). +"""MCP Resource Server with multi-protocol auth (PRM-only discovery variant). This variant: - Exposes PRM with mcp_auth_protocols and authorization_servers @@ -242,4 +241,3 @@ def main( if __name__ == "__main__": main() # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py index 92016a525..fa60c5b15 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py @@ -27,9 +27,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" import httpx - if not self.introspection_endpoint.startswith( - ("https://", "http://localhost", "http://127.0.0.1") - ): + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None @@ -75,7 +73,4 @@ def _validate_resource(self, token_data: dict[str, Any]) -> bool: def _is_valid_resource(self, resource: str) -> bool: if not self.resource_url: return False - return check_resource_allowed( - requested_resource=self.resource_url, configured_resource=resource - ) - + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py index 0a326f786..e42a2c28c 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__init__.py @@ -1,2 +1 @@ """MCP Resource Server (multiprotocol, root-only unified discovery variant).""" - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py index 1f187b070..76c2fa7bb 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/__main__.py @@ -2,7 +2,6 @@ import sys -from mcp_simple_auth_multiprotocol_root_only.server import main +from .server import main sys.exit(main()) # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py index 26db620e7..473ab3765 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py @@ -44,9 +44,7 @@ def build_multiprotocol_backend( scopes=api_key_scopes or [], ) mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend( - verifiers=[oauth_verifier, api_key_verifier, mtls_verifier] - ) + backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) dpop_verifier: DPoPProofVerifier | None = None if dpop_enabled: @@ -100,4 +98,3 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, Aut AuthCredentials(result.scopes or []), AuthenticatedUser(result), ) - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py index 134fc1e77..a378eaa36 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with multi-protocol auth (root-only unified discovery variant). +"""MCP Resource Server with multi-protocol auth (root-only unified discovery variant). This variant: - PRM does NOT include mcp_auth_protocols (only authorization_servers) @@ -255,4 +254,3 @@ def main( if __name__ == "__main__": main() # type: ignore[call-arg] - diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py index 51fc081da..52db9b783 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py @@ -27,9 +27,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" import httpx - if not self.introspection_endpoint.startswith( - ("https://", "http://localhost", "http://127.0.0.1") - ): + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None @@ -75,7 +73,4 @@ def _validate_resource(self, token_data: dict[str, Any]) -> bool: def _is_valid_resource(self, resource: str) -> bool: if not self.resource_url: return False - return check_resource_allowed( - requested_resource=self.resource_url, configured_resource=resource - ) - + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index a8efbbc41..f119148f4 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -24,6 +24,7 @@ AuthorizationParams, OAuthAuthorizationServerProvider, RefreshToken, + TokenError, construct_redirect_uri, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken From 1ad8c899a20daf198d2c066d07dc7cb05201ddca Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 15:17:38 +0800 Subject: [PATCH 54/64] test(auth): wrap long oauth2 protocol docstring --- tests/client/auth/test_oauth2_protocol.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index 4ccab7e44..693887115 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -190,7 +190,10 @@ async def test_authenticate_delegates_to_run_authentication_and_returns_oauth_cr oauth2_protocol: OAuth2Protocol, client_metadata: OAuthClientMetadata, ) -> None: - """authenticate(context) delegates to provider.run_authentication and converts current_tokens to OAuthCredentials.""" + """authenticate(context) delegates to provider.run_authentication. + + Converts current_tokens to OAuthCredentials. + """ from unittest.mock import AsyncMock, MagicMock, patch mock_storage = MagicMock() From 76b1d1e18d0f8dafad125954dcd6e048fce7e2c9 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 18:01:26 +0800 Subject: [PATCH 55/64] docs: fix markdownlint in auth multiprotocol docs --- docs/authorization-multiprotocol.md | 19 ++++++++++++------- examples/README.md | 6 +++--- .../simple-auth-multiprotocol/README.md | 7 ++++++- tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md | 5 ++++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md index b3fd4d9d4..38102464b 100644 --- a/docs/authorization-multiprotocol.md +++ b/docs/authorization-multiprotocol.md @@ -58,7 +58,7 @@ Discovery answers: *Which auth protocols does this resource support, and where i - For the protocol list: if the PRM has `mcp_auth_protocols`, use it (priority 1). Otherwise try path-relative `/.well-known/authorization_servers{path}`, then root `/.well-known/authorization_servers`. If both fail and the PRM has `authorization_servers`, use OAuth fallback. - Merge the protocol list with WWW-Authenticate `auth_protocols` if present, then select one via `AuthProtocolRegistry.select_protocol(available, default_protocol, preferences)`. -**Relationship between authorization URL endpoints** +#### Relationship between authorization URL endpoints There are three distinct URL trees involved: @@ -70,9 +70,9 @@ There are three distinct URL trees involved: | **MCP Resource Server (RS)** | `/.well-known/authorization_servers` | RS | Unified protocol discovery (MCP extension): `protocols`, `default_protocol`, `protocol_preferences` | | **MCP Resource Server (RS)** | `/{resource_path}` (e.g. `/mcp`) | RS | Protected MCP endpoint | -**URL tree (example: AS on 9000, RS on 8002)** +#### URL tree (example: AS on 9000, RS on 8002) -``` +```text OAuth Authorization Server (http://localhost:9000) ├── /.well-known/oauth-authorization-server ← OAuth AS metadata ├── /authorize @@ -87,7 +87,7 @@ MCP Resource Server (http://localhost:8002) └── /mcp ← Protected MCP endpoint ``` -**Client discovery order** +#### Client discovery order 1. On 401, read `resource_metadata` from WWW-Authenticate (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). 2. If absent, try the path-based URL: `{origin}/.well-known/oauth-protected-resource{resource_path}` (e.g. `http://localhost:8002/.well-known/oauth-protected-resource/mcp`). @@ -184,9 +184,9 @@ The server exposes protected MCP endpoints and declares supported auth methods v 2. **Unified discovery** — `create_authorization_servers_discovery_routes(protocols, default_protocol, protocol_preferences)` registers `/.well-known/authorization_servers`. The handler returns `{ "protocols": [ AuthProtocolMetadata, ... ] }` plus optional default and preferences. 3. **401 responses** — Middleware (e.g. RequireAuthMiddleware) returns 401 with WWW-Authenticate including at least Bearer (and optionally `resource_metadata`, `auth_protocols`, `default_protocol`, `protocol_preferences`). -**Configuration and URL tree — requirements by server type** +#### Configuration and URL tree — requirements by server type -**Authorization Server (AS) — configuration requirements** +#### Authorization Server (AS) — configuration requirements | Item | Description | |------|-------------| @@ -198,7 +198,7 @@ The server exposes protected MCP endpoints and declares supported auth methods v No changes to the AS are required for multi-protocol itself; the AS need only support standard OAuth 2.0 and (optionally) DPoP-bound tokens. -**MCP Resource Server (RS) — configuration requirements** +#### MCP Resource Server (RS) — configuration requirements | Item | Description | |------|-------------| @@ -355,6 +355,7 @@ If you use `OAuthClientProvider` or `simple-auth-client` and want to add multi-p #### Step 2: Client — switch to MultiProtocolAuthProvider **Before (OAuth only):** + ```python from mcp.client.auth.oauth2 import OAuthClientProvider provider = OAuthClientProvider(...) @@ -362,6 +363,7 @@ client = httpx.AsyncClient(auth=provider) ``` **After (multi-protocol):** + ```python from mcp.client.auth.multi_protocol import MultiProtocolAuthProvider, TokenStorage from mcp.client.auth.registry import AuthProtocolRegistry @@ -388,6 +390,7 @@ provider._http_client = client - Alternatively, use `OAuthTokenStorageAdapter` to wrap storage that supports only OAuthToken. **If you add API Key:** + ```python async def get_tokens(self) -> AuthCredentials | OAuthToken | None: return self._creds # may be OAuthToken or APIKeyCredentials @@ -399,6 +402,7 @@ async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: #### Step 4: Server — add MultiProtocolAuthBackend and PRM extensions **Before (OAuth only):** + ```python # Single OAuth verifier token_verifier = TokenVerifier(...) @@ -407,6 +411,7 @@ oauth_verifier = OAuthTokenVerifier(token_verifier) ``` **After (multi-protocol):** + ```python from mcp.server.auth.verifiers import ( MultiProtocolAuthBackend, diff --git a/examples/README.md b/examples/README.md index 4cb8dec82..1b35bb7ee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,17 +8,17 @@ for real-world servers. - **Server**: [simple-auth-multiprotocol](servers/simple-auth-multiprotocol/) — RS with OAuth, API Key, DPoP, and Mutual TLS (placeholder). -**API Key** +### API Key - Use `MCP_API_KEY` on the client; start RS with `--api-keys=...` (no AS required). - One-command test (from repo root): `MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` -**OAuth + DPoP** +### OAuth + DPoP - Start AS and RS with `--dpop-enabled`; client: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1`. - One-command test (from repo root): `./scripts/run_phase4_dpop_integration_test.sh` (use `MCP_SKIP_OAUTH=1` to skip manual OAuth step). -**Mutual TLS (placeholder)** +### Mutual TLS (placeholder) - mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` - mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` diff --git a/examples/servers/simple-auth-multiprotocol/README.md b/examples/servers/simple-auth-multiprotocol/README.md index fe7a2a94b..18cd90bed 100644 --- a/examples/servers/simple-auth-multiprotocol/README.md +++ b/examples/servers/simple-auth-multiprotocol/README.md @@ -24,11 +24,13 @@ MCP Resource Server example that supports **OAuth 2.0** (introspection), **API K You can run the Resource Server **without** the Authorization Server when using API Key authentication: 1. **Start the Resource Server** (from this directory): + ```bash uv run mcp-simple-auth-multiprotocol-rs --port=8002 --api-keys=demo-api-key-12345 ``` 2. **Run the client** from `examples/clients/simple-auth-multiprotocol-client`: + ```bash MCP_SERVER_URL=http://localhost:8002/mcp MCP_API_KEY=demo-api-key-12345 uv run mcp-simple-auth-multiprotocol-client ``` @@ -47,14 +49,17 @@ DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds the access token to a c `uv run mcp-simple-auth-as --port=9000` 2. **Start this Resource Server with DPoP enabled** (from this directory): + ```bash uv run mcp-simple-auth-multiprotocol-rs --port=8002 --auth-server=http://localhost:9000 --api-keys=demo-api-key-12345 --dpop-enabled ``` 3. **Run the client** with OAuth and DPoP from `examples/clients/simple-auth-multiprotocol-client`: + ```bash MCP_SERVER_URL=http://localhost:8002/mcp MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1 uv run mcp-simple-auth-multiprotocol-client ``` + Complete OAuth in the browser, then at `mcp>` run `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". **One-command verification** (from repo root): @@ -72,7 +77,7 @@ Mutual TLS is a **placeholder** in this example: the server accepts the `mutual_ ## Options - `--port`: RS port (default 8002). -- `--auth-server`: AS URL (default http://localhost:9000). +- `--auth-server`: AS URL (default ). - `--api-keys`: Comma-separated valid API keys (default demo-api-key-12345). - `--oauth-strict`: Enable RFC 8707 resource validation. - `--dpop-enabled`: Enable DPoP proof verification (RFC 9449); use with OAuth. diff --git a/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md b/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md index 93035a9ca..39aaf52cc 100644 --- a/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md +++ b/tests/PHASE1_OAUTH2_REGRESSION_TEST_PLAN.md @@ -47,7 +47,7 @@ Run existing tests to ensure no regressions. Phase 1 does not change call sites: - **RequireAuthMiddleware** - Instantiate with only `(app, required_scopes, resource_metadata_url)`. - - WWW-Authenticate must still start with `Bearer ` and include `error`, `error_description`, and optionally `resource_metadata`; no requirement for `auth_protocols` / `default_protocol` / `protocol_preferences`. + - WWW-Authenticate must still start with `Bearer` and include `error`, `error_description`, and optionally `resource_metadata`; no requirement for `auth_protocols` / `default_protocol` / `protocol_preferences`. - Existing tests in `tests/server/auth/middleware/test_bearer_auth.py` (e.g. `TestRequireAuthMiddleware`) must pass. ### 3.4 Commands @@ -72,12 +72,14 @@ Manual (or script-assisted) run to confirm the full OAuth2 flow still works with 1. **Start Authorization Server (AS)** From `examples/servers/simple-auth`: + ```bash uv run mcp-simple-auth-as --port=9000 ``` 2. **Start Resource Server (RS)** In another terminal, from `examples/servers/simple-auth`: + ```bash uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http ``` @@ -90,6 +92,7 @@ Manual (or script-assisted) run to confirm the full OAuth2 flow still works with 4. **Run client** From `examples/clients/simple-auth-client`: + ```bash MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client ``` From 310d93918819b5957c2066b59e95fe75be1f0e41 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 18:01:31 +0800 Subject: [PATCH 56/64] test(auth): add coverage tests to satisfy 100% gate --- tests/client/auth/test_dpop.py | 11 + tests/client/auth/test_dpop_integration.py | 8 + .../test_multi_protocol_provider_coverage.py | 607 ++++++++++++++++++ tests/client/auth/test_oauth2_protocol.py | 233 +++++++ ...test_oauth2_run_authentication_coverage.py | 350 ++++++++++ .../test_oauth_401_flow_generator_coverage.py | 282 ++++++++ ...t_utils_authorization_servers_discovery.py | 119 ++++ tests/client/test_multi_protocol_provider.py | 21 + tests/client/test_registry.py | 23 + .../auth/test_bearer_auth_middleware.py | 78 +++ .../auth/test_dpop_proof_verifier_coverage.py | 186 ++++++ .../test_token_handler_client_credentials.py | 195 ++++++ tests/server/auth/test_verifiers.py | 8 + tests/server/auth/test_verifiers_dpop.py | 5 + tests/server/test_fastmcp_shim.py | 25 + 15 files changed, 2151 insertions(+) create mode 100644 tests/client/auth/test_multi_protocol_provider_coverage.py create mode 100644 tests/client/auth/test_oauth2_run_authentication_coverage.py create mode 100644 tests/client/auth/test_oauth_401_flow_generator_coverage.py create mode 100644 tests/client/auth/test_utils_authorization_servers_discovery.py create mode 100644 tests/server/auth/test_bearer_auth_middleware.py create mode 100644 tests/server/auth/test_dpop_proof_verifier_coverage.py create mode 100644 tests/server/auth/test_token_handler_client_credentials.py create mode 100644 tests/server/test_fastmcp_shim.py diff --git a/tests/client/auth/test_dpop.py b/tests/client/auth/test_dpop.py index 6452de83d..82f43a9d6 100644 --- a/tests/client/auth/test_dpop.py +++ b/tests/client/auth/test_dpop.py @@ -2,6 +2,7 @@ import base64 import hashlib +from typing import Any, cast import jwt import pytest @@ -117,3 +118,13 @@ def test_dpop_key_pair_generate_rs256_custom_key_size() -> None: def test_dpop_key_pair_generate_rs256_rejects_small_key_size() -> None: with pytest.raises(ValueError, match="RSA key size must be at least 2048"): DPoPKeyPair.generate("RS256", rsa_key_size=1024) + + +def test_dpop_key_pair_generate_rejects_unsupported_algorithm() -> None: + with pytest.raises(ValueError, match="Unsupported algorithm"): + DPoPKeyPair.generate(cast(Any, "HS256")) + + +def test_compute_jwk_thumbprint_rejects_unsupported_key_type() -> None: + with pytest.raises(ValueError, match="Unsupported key type"): + compute_jwk_thumbprint({"kty": "oct"}) diff --git a/tests/client/auth/test_dpop_integration.py b/tests/client/auth/test_dpop_integration.py index 8aa964c4f..01b444290 100644 --- a/tests/client/auth/test_dpop_integration.py +++ b/tests/client/auth/test_dpop_integration.py @@ -121,6 +121,14 @@ async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: self._tokens = tokens +@pytest.mark.anyio +async def test_mock_storage_set_tokens_is_exercised_for_coverage() -> None: + storage = MockStorage() + token = OAuthToken(access_token="at", token_type="Bearer", expires_in=3600) + await storage.set_tokens(token) + assert await storage.get_tokens() is token + + @pytest.mark.anyio async def test_multi_protocol_provider_dpop_header_injection( client_metadata: OAuthClientMetadata, diff --git a/tests/client/auth/test_multi_protocol_provider_coverage.py b/tests/client/auth/test_multi_protocol_provider_coverage.py new file mode 100644 index 000000000..2ec3b64ed --- /dev/null +++ b/tests/client/auth/test_multi_protocol_provider_coverage.py @@ -0,0 +1,607 @@ +"""Additional coverage tests for MultiProtocolAuthProvider.""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx +import pytest +from pydantic import AnyHttpUrl + +from mcp.client.auth.multi_protocol import ( + MultiProtocolAuthProvider, + OAuthTokenStorageAdapter, + TokenStorage, + _credentials_to_storage, + _oauth_token_to_credentials, +) +from mcp.client.auth.protocol import AuthContext, DPoPProofGenerator +from mcp.client.auth.protocols.oauth2 import OAuth2Protocol +from mcp.shared.auth import ( + APIKeyCredentials, + AuthCredentials, + AuthProtocolMetadata, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthCredentials, + OAuthToken, + ProtectedResourceMetadata, +) + + +class _InMemoryDualStorage(TokenStorage): + def __init__(self) -> None: + self._tokens: AuthCredentials | OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> AuthCredentials | OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: AuthCredentials | OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class _ApiKeyProtocol: + protocol_id = "api_key" + protocol_version = "1.0" + + def __init__(self, api_key: str, *, should_raise: bool = False) -> None: + self._api_key = api_key + self._should_raise = should_raise + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + if self._should_raise: + raise RuntimeError("api_key auth failed") + return APIKeyCredentials(protocol_id="api_key", api_key=self._api_key) + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + assert isinstance(credentials, APIKeyCredentials) + request.headers["X-API-Key"] = credentials.api_key + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return isinstance(credentials, APIKeyCredentials) and bool(credentials.api_key) + + async def discover_metadata( + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + +def test_oauth_token_to_credentials_leaves_expires_at_none_when_expires_in_missing() -> None: + credentials = _oauth_token_to_credentials(OAuthToken(access_token="at", token_type="Bearer", expires_in=None)) + assert credentials.expires_at is None + + +@pytest.mark.anyio +async def test_helper_types_are_exercised_for_test_coverage() -> None: + storage = _InMemoryDualStorage() + assert await storage.get_client_info() is None + info = OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/callback")]) + await storage.set_client_info(info) + assert await storage.get_client_info() is info + + protocol = _ApiKeyProtocol("k") + assert await protocol.discover_metadata(None) is None + + +def test_credentials_to_storage_calculates_expires_in(monkeypatch: pytest.MonkeyPatch) -> None: + now = 1_700_000_000 + monkeypatch.setattr(time, "time", lambda: now) + + later = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + token_type="Bearer", + refresh_token=None, + scope=None, + expires_at=now + 10, + ) + out = _credentials_to_storage(later) + assert isinstance(out, OAuthToken) + assert out.expires_in == 10 + + past = OAuthCredentials( + protocol_id="oauth2", + access_token="at", + token_type="Bearer", + refresh_token=None, + scope=None, + expires_at=now - 1, + ) + out2 = _credentials_to_storage(past) + assert isinstance(out2, OAuthToken) + assert out2.expires_in == 0 + + +@pytest.mark.anyio +async def test_parse_protocols_from_discovery_response_falls_back_to_prm_on_invalid_json() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + + from mcp.shared.auth import ProtectedResourceMetadata + + prm_validated = ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/"], + "mcp_auth_protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}], + } + ) + + response = httpx.Response(200, content=b"{not-json", request=httpx.Request("GET", "https://rs/.well-known/x")) + protocols = await provider._parse_protocols_from_discovery_response(response, prm_validated) + assert [p.protocol_id for p in protocols] == ["api_key"] + + +@pytest.mark.anyio +async def test_parse_protocols_from_discovery_response_falls_back_to_prm_when_protocols_list_empty() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + + prm_validated = ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/"], + "mcp_auth_protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}], + } + ) + + response = httpx.Response( + 200, + json={"protocols": []}, + request=httpx.Request("GET", "https://rs/mcp/.well-known/authorization_servers"), + ) + protocols = await provider._parse_protocols_from_discovery_response(response, prm_validated) + assert [p.protocol_id for p in protocols] == ["api_key"] + + +@pytest.mark.anyio +async def test_handle_403_response_parses_fields() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + request = httpx.Request("GET", "https://rs.example/mcp") + response = httpx.Response( + 403, + headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="read write"'}, + request=request, + ) + await provider._handle_403_response(response, request) + + +@pytest.mark.anyio +async def test_handle_403_response_no_header_exits_early() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + request = httpx.Request("GET", "https://rs.example/mcp") + response = httpx.Response(403, request=request) + await provider._handle_403_response(response, request) + + +@pytest.mark.anyio +async def test_oauth_token_storage_adapter_does_not_persist_non_oauth_credentials() -> None: + called: list[OAuthToken] = [] + + class _Wrapped: + async def get_tokens(self) -> OAuthToken | None: + return None + + async def set_tokens(self, tokens: OAuthToken) -> None: + called.append(tokens) + + adapter = OAuthTokenStorageAdapter(_Wrapped()) + assert await adapter.get_tokens() is None + await adapter.set_tokens(APIKeyCredentials(protocol_id="api_key", api_key="k")) + assert called == [] + token = OAuthToken(access_token="at", token_type="Bearer") + await _Wrapped().set_tokens(token) + assert called == [token] + + +class _DummyDpopGenerator: + def __init__(self) -> None: + self.seen_credential: str | None = "unset" + + def generate_proof(self, method: str, uri: str, credential: str | None = None, nonce: str | None = None) -> str: + self.seen_credential = credential + return "proof" + + def get_public_key_jwk(self) -> dict[str, Any]: + return {"kty": "EC"} + + +class _DpopProtocolBase: + protocol_version = "1.0" + + def __init__(self, protocol_id: str) -> None: + self.protocol_id = protocol_id + self.initialize_called = False + + async def authenticate(self, context: AuthContext) -> AuthCredentials: + return APIKeyCredentials(protocol_id=self.protocol_id, api_key="k") + + def prepare_request(self, request: httpx.Request, credentials: AuthCredentials) -> None: + request.headers["x-auth"] = "ok" + + def validate_credentials(self, credentials: AuthCredentials) -> bool: + return True + + async def discover_metadata( + self, + metadata_url: str | None, + prm: ProtectedResourceMetadata | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> AuthProtocolMetadata | None: + return None + + def supports_dpop(self) -> bool: + return False + + def get_dpop_proof_generator(self) -> DPoPProofGenerator | None: + return None + + async def initialize_dpop(self) -> None: + self.initialize_called = True + + +@pytest.mark.anyio +async def test_ensure_dpop_initialized_skips_when_protocol_not_dpop_enabled() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[_ApiKeyProtocol("k")], + dpop_enabled=True, + ) + provider._initialize() + await provider._ensure_dpop_initialized(APIKeyCredentials(protocol_id="api_key", api_key="k")) + + +@pytest.mark.anyio +async def test_ensure_dpop_initialized_skips_when_supports_dpop_false() -> None: + storage = _InMemoryDualStorage() + protocol = _DpopProtocolBase("oauth2") + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + dpop_enabled=True, + ) + provider._initialize() + + await provider._ensure_dpop_initialized(OAuthCredentials(protocol_id="oauth2", access_token="at")) + assert protocol.initialize_called is False + + +@pytest.mark.anyio +async def test_prepare_request_dpop_enabled_but_supports_dpop_false_does_not_set_dpop_header() -> None: + storage = _InMemoryDualStorage() + protocol = _DpopProtocolBase("oauth2") + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + dpop_enabled=True, + ) + provider._initialize() + + request = httpx.Request("GET", "https://rs.example/mcp") + provider._prepare_request(request, OAuthCredentials(protocol_id="oauth2", access_token="at")) + assert "dpop" not in request.headers + + +@pytest.mark.anyio +async def test_prepare_request_dpop_enabled_generator_none_does_not_set_dpop_header() -> None: + storage = _InMemoryDualStorage() + + class _NoGeneratorProtocol(_DpopProtocolBase): + def supports_dpop(self) -> bool: + return True + + protocol = _NoGeneratorProtocol("oauth2") + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + dpop_enabled=True, + ) + provider._initialize() + + request = httpx.Request("GET", "https://rs.example/mcp") + provider._prepare_request(request, OAuthCredentials(protocol_id="oauth2", access_token="at")) + assert "dpop" not in request.headers + + +@pytest.mark.anyio +async def test_prepare_request_dpop_includes_proof_and_passes_none_credential_for_non_oauth_credentials() -> None: + storage = _InMemoryDualStorage() + generator = _DummyDpopGenerator() + + class _WithGeneratorProtocol(_DpopProtocolBase): + def supports_dpop(self) -> bool: + return True + + def get_dpop_proof_generator(self) -> Any: + return generator + + protocol = _WithGeneratorProtocol("api_key") + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + dpop_enabled=True, + ) + provider._initialize() + + request = httpx.Request("GET", "https://rs.example/mcp") + provider._prepare_request(request, APIKeyCredentials(protocol_id="api_key", api_key="k")) + assert request.headers["dpop"] == "proof" + assert generator.seen_credential is None + assert generator.get_public_key_jwk() == {"kty": "EC"} + + +@pytest.mark.anyio +async def test_dpop_protocol_base_helpers_are_exercised_for_test_coverage() -> None: + protocol = _DpopProtocolBase("api_key") + context = AuthContext(server_url="https://rs.example/mcp", storage=_InMemoryDualStorage(), protocol_id="api_key") + credentials = await protocol.authenticate(context) + assert protocol.validate_credentials(credentials) is True + assert await protocol.discover_metadata(None) is None + await protocol.initialize_dpop() + assert protocol.initialize_called is True + + +@pytest.mark.anyio +async def test_async_auth_flow_returns_response_when_already_initialized() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + provider._initialize() + + request = httpx.Request("GET", "https://rs.example/mcp") + flow = provider.async_auth_flow(request) + yielded_request = await flow.__anext__() + assert yielded_request is request + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, request=request)) + + +@pytest.mark.anyio +async def test_401_flow_api_key_success_with_preferences_and_default_skips_uninjected() -> None: + api_key = "k1" + seen_api_key: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and "oauth-protected-resource" in request.url.path: + return httpx.Response( + 200, + json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, + request=request, + ) + if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response( + 200, + json={"protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}]}, + request=request, + ) + if request.method == "POST" and request.url.path == "/mcp": + seen_api_key.append(request.headers.get("x-api-key")) + if request.headers.get("x-api-key") == api_key: + return httpx.Response(200, json={"ok": True}, request=request) + www = ( + 'Bearer error="invalid_token", ' + 'resource_metadata="https://rs.example/.well-known/oauth-protected-resource/mcp", ' + 'auth_protocols="oauth2 api_key", ' + 'default_protocol="oauth2", ' + 'protocol_preferences="api_key:1,oauth2:10"' + ) + return httpx.Response(401, headers={"WWW-Authenticate": www}, request=request) + return httpx.Response(404, request=request) + + storage = _InMemoryDualStorage() + protocol = _ApiKeyProtocol(api_key) + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=provider) as client: + response = await client.post("https://rs.example/mcp", json={"ping": True}) + + assert response.status_code == 200 + assert seen_api_key[0] is None + assert api_key in seen_api_key + assert handler(httpx.Request("GET", "https://rs.example/other")).status_code == 404 + + +@pytest.mark.anyio +async def test_401_flow_api_key_failure_surfaces_last_auth_error() -> None: + api_key = "k1" + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and "oauth-protected-resource" in request.url.path: + return httpx.Response( + 200, + json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, + request=request, + ) + if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response( + 200, + json={"protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}]}, + request=request, + ) + if request.method == "POST" and request.url.path == "/mcp": + www = ( + 'Bearer error="invalid_token", ' + 'resource_metadata="https://rs.example/.well-known/oauth-protected-resource/mcp", ' + 'auth_protocols="api_key"' + ) + return httpx.Response(401, headers={"WWW-Authenticate": www}, request=request) + return httpx.Response(404, request=request) + + storage = _InMemoryDualStorage() + protocol = _ApiKeyProtocol(api_key, should_raise=True) + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[protocol], + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=provider) as client: + with pytest.raises(RuntimeError, match="api_key auth failed"): + await client.post("https://rs.example/mcp", json={"ping": True}) + assert handler(httpx.Request("GET", "https://rs.example/other")).status_code == 404 + + +@pytest.mark.anyio +async def test_401_flow_oauth2_fallback_via_prm_authorization_servers_client_credentials() -> None: + storage = _InMemoryDualStorage() + fixed_client_info = OAuthClientInformationFull( + client_id="client", + client_secret="secret", + token_endpoint_auth_method="client_secret_post", + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + ) + client_metadata = OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ) + oauth2 = OAuth2Protocol( + client_metadata=client_metadata, + fixed_client_info=fixed_client_info, + ) + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[oauth2], + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and "oauth-protected-resource" in request.url.path: + return httpx.Response( + 200, + json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, + request=request, + ) + if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(404, request=request) + if request.method == "GET" and request.url.path == "/.well-known/oauth-authorization-server": + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + }, + request=request, + ) + if request.method == "POST" and request.url.path == "/token": + return httpx.Response( + 200, + json={"access_token": "at", "token_type": "Bearer", "expires_in": 3600}, + request=request, + ) + if request.method == "POST" and request.url.path == "/mcp": + if request.headers.get("authorization") == "Bearer at": + return httpx.Response(200, json={"ok": True}, request=request) + www = 'Bearer error="invalid_token", resource_metadata="https://rs.example/.well-known/oauth-protected-resource/mcp"' + return httpx.Response(401, headers={"WWW-Authenticate": www}, request=request) + return httpx.Response(404, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=provider) as client: + response = await client.post("https://rs.example/mcp", json={"ping": True}) + + assert response.status_code == 200 + assert handler(httpx.Request("GET", "https://rs.example/other")).status_code == 404 + + +@pytest.mark.anyio +async def test_async_auth_flow_handles_403_response() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider( + server_url="https://rs.example/mcp", + storage=storage, + protocols=[], + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/mcp": + return httpx.Response( + 403, + headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="read"'}, + request=request, + ) + return httpx.Response(404, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=provider) as client: + response = await client.get("https://rs.example/mcp") + + assert response.status_code == 403 + assert handler(httpx.Request("GET", "https://rs.example/other")).status_code == 404 + + +@pytest.mark.anyio +async def test_401_flow_no_hints_no_prm_no_protocols_retries_original_request() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + post_calls: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/mcp": + post_calls.append(1) + if len(post_calls) == 1: + return httpx.Response( + 401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, request=request + ) + return httpx.Response(200, json={"ok": True}, request=request) + if request.method == "GET" and "oauth-protected-resource" in request.url.path: + return httpx.Response(404, request=request) + if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(200, json={"protocols": []}, request=request) + return httpx.Response(404, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=provider) as client: + response = await client.post("https://rs.example/mcp", json={"ping": True}) + + assert response.status_code == 200 + assert len(post_calls) == 2 + assert handler(httpx.Request("GET", "https://unexpected.example/other")).status_code == 404 + + +@pytest.mark.anyio +async def test_401_flow_skips_prm_discovery_when_prm_urls_empty(monkeypatch: pytest.MonkeyPatch) -> None: + import mcp.client.auth.multi_protocol as multi_protocol_module + + def build_urls(www_auth_url: str | None, server_url: str) -> list[str]: + return [] + + monkeypatch.setattr(multi_protocol_module, "build_protected_resource_metadata_discovery_urls", build_urls) + + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + request = httpx.Request("POST", "https://rs.example/mcp", json={"ping": True}) + + flow = provider.async_auth_flow(request) + yielded_request = await flow.__anext__() + assert yielded_request is request + + discovery_request = await flow.asend( + httpx.Response(401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, request=request) + ) + assert discovery_request.url.path.endswith("/mcp/.well-known/authorization_servers") + + retry_request = await flow.asend(httpx.Response(200, json={"protocols": []}, request=discovery_request)) + assert retry_request is request + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, json={"ok": True}, request=request)) diff --git a/tests/client/auth/test_oauth2_protocol.py b/tests/client/auth/test_oauth2_protocol.py index 693887115..7dd6d829f 100644 --- a/tests/client/auth/test_oauth2_protocol.py +++ b/tests/client/auth/test_oauth2_protocol.py @@ -239,3 +239,236 @@ async def test_authenticate_delegates_to_run_authentication_and_returns_oauth_cr assert creds.access_token == "returned-token" assert creds.scope == "read" assert creds.refresh_token == "rt" + + +def test_oauth_metadata_to_protocol_metadata_includes_optional_endpoints() -> None: + from pydantic import AnyHttpUrl + + from mcp.client.auth.protocols.oauth2 import _oauth_metadata_to_protocol_metadata + from mcp.shared.auth import OAuthMetadata + + asm = OAuthMetadata.model_validate( + { + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + "registration_endpoint": "https://as.example/register", + "revocation_endpoint": "https://as.example/revoke", + "introspection_endpoint": "https://as.example/introspect", + "scopes_supported": ["read"], + "grant_types_supported": ["client_credentials"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + } + ) + meta = _oauth_metadata_to_protocol_metadata(asm) + assert meta.protocol_id == "oauth2" + assert meta.endpoints is not None + assert meta.endpoints["authorization_endpoint"] == AnyHttpUrl("https://as.example/authorize") + assert meta.endpoints["token_endpoint"] == AnyHttpUrl("https://as.example/token") + assert meta.endpoints["registration_endpoint"] == AnyHttpUrl("https://as.example/register") + assert meta.endpoints["revocation_endpoint"] == AnyHttpUrl("https://as.example/revoke") + assert meta.endpoints["introspection_endpoint"] == AnyHttpUrl("https://as.example/introspect") + + +def test_token_to_oauth_credentials_sets_expires_at_when_expires_in_present() -> None: + from mcp.client.auth.protocols.oauth2 import _token_to_oauth_credentials + + creds = _token_to_oauth_credentials(OAuthToken(access_token="at", token_type="Bearer", expires_in=1)) + assert creds.access_token == "at" + assert creds.expires_at is not None + + creds2 = _token_to_oauth_credentials(OAuthToken(access_token="at", token_type="Bearer", expires_in=None)) + assert creds2.expires_at is None + + +@pytest.mark.anyio +async def test_authenticate_reads_protocol_version_and_raises_when_provider_has_no_tokens( + oauth2_protocol: OAuth2Protocol, +) -> None: + from unittest.mock import AsyncMock, MagicMock, patch + + from mcp.shared.auth import AuthProtocolMetadata + + mock_storage = MagicMock() + mock_storage.get_tokens = AsyncMock(return_value=None) + mock_storage.get_client_info = AsyncMock(return_value=None) + mock_storage.set_tokens = AsyncMock() + mock_storage.set_client_info = AsyncMock() + + mock_provider = MagicMock() + mock_provider.context = MagicMock(current_tokens=None) + mock_provider.run_authentication = AsyncMock() + + context = AuthContext( + server_url="https://example.com", + storage=mock_storage, + protocol_id="oauth2", + protocol_metadata=AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2025-06-18"), + current_credentials=None, + dpop_storage=None, + dpop_enabled=False, + http_client=None, + protected_resource_metadata=None, + scope_from_www_auth=None, + ) + + with patch("mcp.client.auth.protocols.oauth2.OAuthClientProvider", return_value=mock_provider): + with pytest.raises(RuntimeError, match="no tokens"): + await oauth2_protocol.authenticate(context) + + +@pytest.mark.anyio +async def test_discover_metadata_network_path_uses_prm_authorization_server_when_metadata_url_missing( + client_metadata: OAuthClientMetadata, +) -> None: + protocol = OAuth2Protocol(client_metadata=client_metadata) + + prm = ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/tenant"], + "mcp_auth_protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}], + } + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "as.example": + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + }, + request=request, + ) + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + meta = await protocol.discover_metadata(metadata_url=None, prm=prm, http_client=http_client) + + assert meta is not None + assert meta.protocol_id == "oauth2" + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_initialize_dpop_is_idempotent_when_enabled(client_metadata: OAuthClientMetadata) -> None: + protocol = OAuth2Protocol(client_metadata=client_metadata, dpop_enabled=True) + assert protocol.get_dpop_public_key_jwk() is None + await protocol.initialize_dpop() + await protocol.initialize_dpop() + + +@pytest.mark.anyio +async def test_discover_metadata_prefers_metadata_url_over_prm_authorization_servers( + client_metadata: OAuthClientMetadata, +) -> None: + protocol = OAuth2Protocol(client_metadata=client_metadata) + prm = ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example/tenant"], + } + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "override.example": + return httpx.Response( + 200, + json={ + "issuer": "https://override.example", + "authorization_endpoint": "https://override.example/authorize", + "token_endpoint": "https://override.example/token", + }, + request=request, + ) + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + meta = await protocol.discover_metadata( + metadata_url="https://override.example/.well-known/oauth-authorization-server", + prm=prm, + http_client=http_client, + ) + + assert meta is not None + assert meta.metadata_url is not None + assert str(meta.metadata_url).startswith("https://override.example/") + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_discover_metadata_breaks_on_non_4xx_error(client_metadata: OAuthClientMetadata) -> None: + protocol = OAuth2Protocol(client_metadata=client_metadata) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + meta = await protocol.discover_metadata( + metadata_url="https://as.example/.well-known/oauth-authorization-server", + prm=None, + http_client=http_client, + ) + + assert meta is None + assert handler(httpx.Request("GET", "https://unexpected.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_discover_metadata_continues_after_validation_error_and_handles_send_exception( + client_metadata: OAuthClientMetadata, +) -> None: + protocol = OAuth2Protocol(client_metadata=client_metadata) + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url.endswith("/.well-known/oauth-authorization-server/tenant"): + return httpx.Response(200, content=b"{bad-json", request=request) + if url.endswith("/.well-known/openid-configuration/tenant"): + raise RuntimeError("network down") + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + }, + request=request, + ) + + prm = ProtectedResourceMetadata.model_validate( + {"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/tenant"]} + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + meta = await protocol.discover_metadata(metadata_url=None, prm=prm, http_client=http_client) + + assert meta is not None + + +@pytest.mark.anyio +async def test_discover_metadata_returns_none_when_discovery_urls_are_empty( + client_metadata: OAuthClientMetadata, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import mcp.client.auth.protocols.oauth2 as oauth2_protocol_module + + def build_urls(auth_server_url: str | None, server_url: str) -> list[str]: + return [] + + monkeypatch.setattr(oauth2_protocol_module, "build_oauth_authorization_server_metadata_discovery_urls", build_urls) + protocol = OAuth2Protocol(client_metadata=client_metadata) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + meta = await protocol.discover_metadata( + metadata_url="https://as.example/.well-known/oauth-authorization-server", + prm=None, + http_client=http_client, + ) + + assert meta is None + assert handler(httpx.Request("GET", "https://unexpected.example/unexpected")).status_code == 500 diff --git a/tests/client/auth/test_oauth2_run_authentication_coverage.py b/tests/client/auth/test_oauth2_run_authentication_coverage.py new file mode 100644 index 000000000..e03b7ba2a --- /dev/null +++ b/tests/client/auth/test_oauth2_run_authentication_coverage.py @@ -0,0 +1,350 @@ +"""Additional coverage tests for OAuthClientProvider.run_authentication and client_credentials.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from pydantic import AnyHttpUrl + +from mcp.client.auth.exceptions import OAuthFlowError +from mcp.client.auth.oauth2 import OAuthClientProvider +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken, ProtectedResourceMetadata + + +class _InMemoryOAuthStorage: + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + self._client_info: Any = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> Any: + return self._client_info + + async def set_client_info(self, client_info: Any) -> None: + self._client_info = client_info + + +@pytest.mark.anyio +async def test_in_memory_oauth_storage_getters_are_exercised_for_test_coverage() -> None: + storage = _InMemoryOAuthStorage() + assert await storage.get_tokens() is None + assert await storage.get_client_info() is None + + +@pytest.mark.anyio +async def test_exchange_token_client_credentials_requires_client_info() -> None: + storage = _InMemoryOAuthStorage() + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=storage, + fixed_client_info=None, + ) + with pytest.raises(OAuthFlowError, match="Missing client info"): + await provider._exchange_token_client_credentials() + + +@pytest.mark.anyio +async def test_run_authentication_with_prm_and_oasm_discovery_errors_then_cimd_then_client_credentials() -> None: + storage = _InMemoryOAuthStorage() + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + scope="read", + ), + storage=storage, + client_metadata_url="https://client.example/metadata.json", + ) + + # PRM success response (second URL in fallback chain) + prm_json = b'{"resource":"https://rs.example/mcp","authorization_servers":["https://as.example/tenant"]}' + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url == "https://rs.example/custom_prm": + raise RuntimeError("network down") + if url.startswith("https://rs.example/.well-known/oauth-protected-resource"): + return httpx.Response(200, content=prm_json, request=request) + + if url == "https://as.example/.well-known/oauth-authorization-server/tenant": + raise RuntimeError("oasm transient error") + if url == "https://as.example/.well-known/openid-configuration/tenant": + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + "client_id_metadata_document_supported": True, + }, + request=request, + ) + + if url == "https://as.example/token": + return httpx.Response( + 200, + json={"access_token": "at", "token_type": "Bearer", "expires_in": 3600, "scope": "read"}, + request=request, + ) + + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + await provider.run_authentication( + http_client, + resource_metadata_url="https://rs.example/custom_prm", + ) + + assert storage._tokens is not None + assert storage._tokens.access_token == "at" + assert storage._client_info is not None + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_run_authentication_uses_dcr_when_cimd_not_supported() -> None: + storage = _InMemoryOAuthStorage() + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=storage, + ) + + prm = ProtectedResourceMetadata.model_validate( + {"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example"]} + ) + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url == "https://as.example/.well-known/oauth-authorization-server": + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + "registration_endpoint": "https://as.example/register", + }, + request=request, + ) + if url == "https://as.example/register": + return httpx.Response( + 201, + content=b'{"client_id":"cid","client_secret":"sec","redirect_uris":["http://localhost/callback"],"token_endpoint_auth_method":"client_secret_post"}', + request=request, + ) + if url == "https://as.example/token": + return httpx.Response( + 200, + json={"access_token": "at2", "token_type": "Bearer", "expires_in": 3600}, + request=request, + ) + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + await provider.run_authentication(http_client, protected_resource_metadata=prm) + + assert storage._tokens is not None + assert storage._tokens.access_token == "at2" + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_exchange_token_client_credentials_includes_optional_fields_conditionally() -> None: + storage = _InMemoryOAuthStorage() + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + scope=None, + ), + storage=storage, + fixed_client_info=None, + ) + provider.context.client_info = OAuthClientInformationFull( + client_id="", + client_secret=None, + token_endpoint_auth_method="none", + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + ) + + request = await provider._exchange_token_client_credentials() + body = request.content.decode() + assert "grant_type=client_credentials" in body + assert "client_id=" not in body + assert "resource=" not in body + assert "scope=" not in body + + provider2 = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + scope="read", + ), + storage=_InMemoryOAuthStorage(), + fixed_client_info=OAuthClientInformationFull.model_validate( + { + "client_id": "cid", + "client_secret": "sec", + "token_endpoint_auth_method": "client_secret_post", + "redirect_uris": ["http://localhost/callback"], + } + ), + ) + provider2.context.protected_resource_metadata = ProtectedResourceMetadata.model_validate( + {"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example"]} + ) + req2 = await provider2._exchange_token_client_credentials() + body2 = req2.content.decode() + assert "client_id=cid" in body2 + assert "resource=" in body2 + assert "scope=read" in body2 + + +@pytest.mark.anyio +async def test_run_authentication_handles_protected_resource_metadata_without_authorization_servers() -> None: + storage = _InMemoryOAuthStorage() + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=storage, + client_metadata_url="https://client.example/metadata.json", + ) + protected_resource_metadata = ProtectedResourceMetadata.model_construct( + resource="https://rs.example/mcp", + authorization_servers=[], + ) + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url.startswith("https://rs.example/.well-known/oauth-protected-resource"): + return httpx.Response( + 200, + json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example"]}, + request=request, + ) + if url == "https://as.example/.well-known/oauth-authorization-server": + return httpx.Response( + 200, + json={ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token", + "client_id_metadata_document_supported": True, + }, + request=request, + ) + if url == "https://as.example/token": + return httpx.Response( + 200, + json={"access_token": "at3", "token_type": "Bearer", "expires_in": 3600}, + request=request, + ) + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + await provider.run_authentication( + http_client, + protected_resource_metadata=protected_resource_metadata, + resource_metadata_url="https://rs.example/.well-known/oauth-protected-resource/mcp", + ) + + assert storage._tokens is not None + assert storage._tokens.access_token == "at3" + assert handler(httpx.Request("GET", "https://unexpected.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_run_authentication_raises_when_prm_has_no_authorization_servers() -> None: + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=_InMemoryOAuthStorage(), + ) + + protected_resource_metadata = ProtectedResourceMetadata.model_construct( + resource="https://rs.example/mcp", + authorization_servers=[], + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + with pytest.raises(OAuthFlowError, match="Could not discover authorization server"): + await provider.run_authentication(http_client, protected_resource_metadata=protected_resource_metadata) + + assert handler(httpx.Request("GET", "https://unexpected.example/unexpected")).status_code == 500 + + +@pytest.mark.anyio +async def test_run_authentication_sets_prm_but_does_not_set_auth_server_url_when_prm_has_no_authorization_servers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import mcp.client.auth.oauth2 as oauth2_module + + provider = OAuthClientProvider( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=_InMemoryOAuthStorage(), + fixed_client_info=OAuthClientInformationFull( + client_id="cid", + client_secret="sec", + token_endpoint_auth_method="client_secret_post", + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + ), + ) + + prm_without_authorization_servers = ProtectedResourceMetadata.model_construct( + resource="https://rs.example/mcp", + authorization_servers=[], + ) + + async def fake_handle_protected_resource_response(_: httpx.Response) -> ProtectedResourceMetadata | None: + return prm_without_authorization_servers + + monkeypatch.setattr(oauth2_module, "handle_protected_resource_response", fake_handle_protected_resource_response) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + with pytest.raises(OAuthFlowError, match="Could not discover authorization server"): + await provider.run_authentication( + http_client, + resource_metadata_url="https://rs.example/.well-known/oauth-protected-resource/mcp", + ) diff --git a/tests/client/auth/test_oauth_401_flow_generator_coverage.py b/tests/client/auth/test_oauth_401_flow_generator_coverage.py new file mode 100644 index 000000000..44a70e589 --- /dev/null +++ b/tests/client/auth/test_oauth_401_flow_generator_coverage.py @@ -0,0 +1,282 @@ +"""Coverage tests for oauth_401_flow_generator and Protocol stubs. + +These tests intentionally exercise Protocol method bodies (``...``) to satisfy branch coverage. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import httpx +import pytest +from pydantic import AnyHttpUrl + +import mcp.client.auth._oauth_401_flow as _oauth_401_flow +from mcp.client.auth._oauth_401_flow import _OAuth401FlowProvider, oauth_401_flow_generator, oauth_403_flow_generator +from mcp.client.auth.exceptions import OAuthFlowError +from mcp.client.auth.multi_protocol import _OAuthTokenOnlyStorage +from mcp.client.auth.protocol import ( + AuthContext, + AuthProtocol, + DPoPEnabledProtocol, + DPoPProofGenerator, + DPoPStorage, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken, ProtectedResourceMetadata + + +class _NoopStorage: + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + return None + + +@dataclass +class _DummyOAuthContext: + server_url: str + client_metadata: OAuthClientMetadata + storage: Any + client_metadata_url: str | None = None + protected_resource_metadata: ProtectedResourceMetadata | None = None + oauth_metadata: Any = None + auth_server_url: str | None = None + client_info: OAuthClientInformationFull | None = None + + def get_authorization_base_url(self, server_url: str) -> str: + return server_url.rstrip("/") + + +class _DummyProvider: + def __init__(self, ctx: _DummyOAuthContext) -> None: + self.context = ctx + self._token_request = httpx.Request("POST", "https://as.example/token") + + async def _perform_authorization(self) -> httpx.Request: + return self._token_request + + async def _handle_token_response(self, response: httpx.Response) -> None: + await response.aread() + + +def _prm(*, auth_server: str) -> ProtectedResourceMetadata: + return ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": [auth_server], + "scopes_supported": ["read"], + } + ) + + +def _oauth_metadata_response(*, request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=b"""{ + "issuer": "https://as.example", + "authorization_endpoint": "https://as.example/authorize", + "token_endpoint": "https://as.example/token" + }""", + request=request, + ) + + +@pytest.mark.anyio +async def test_dummy_context_and_storage_helpers_are_exercised_for_coverage() -> None: + storage = _NoopStorage() + await storage.set_client_info( + OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]) + ) + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=storage, + ) + assert ctx.get_authorization_base_url("https://example.com/x/") == "https://example.com/x" + + +@pytest.mark.anyio +async def test_oauth_401_flow_generator_initial_prm_sets_auth_server_url() -> None: + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=_NoopStorage(), + client_info=OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]), + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_401 = httpx.Response(401, headers={"WWW-Authenticate": 'Bearer scope="read"'}, request=request) + + flow = oauth_401_flow_generator(provider, request, response_401, initial_prm=_prm(auth_server="https://as.example")) + oauth_metadata_req = await flow.__anext__() + + assert ctx.auth_server_url == "https://as.example/" + + token_req = await flow.asend(_oauth_metadata_response(request=oauth_metadata_req)) + assert token_req.method == "POST" + + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, content=b"{}", request=token_req)) + + +@pytest.mark.anyio +async def test_oauth_401_flow_generator_initial_prm_without_authorization_servers_uses_legacy_oasm_discovery() -> None: + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=_NoopStorage(), + client_info=OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]), + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_401 = httpx.Response(401, headers={"WWW-Authenticate": 'Bearer scope="read"'}, request=request) + prm = ProtectedResourceMetadata.model_construct( + resource="https://rs.example/mcp", + authorization_servers=[], + ) + + flow = oauth_401_flow_generator(provider, request, response_401, initial_prm=prm) + oauth_metadata_req = await flow.__anext__() + token_req = await flow.asend(_oauth_metadata_response(request=oauth_metadata_req)) + + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, content=b"{}", request=token_req)) + + +@pytest.mark.anyio +async def test_oauth_401_flow_generator_breaks_oasm_discovery_on_server_error() -> None: + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=_NoopStorage(), + client_info=OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]), + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_401 = httpx.Response(401, headers={"WWW-Authenticate": 'Bearer scope="read"'}, request=request) + + flow = oauth_401_flow_generator(provider, request, response_401, initial_prm=_prm(auth_server="https://as.example")) + oauth_metadata_req = await flow.__anext__() + + token_req = await flow.asend(httpx.Response(500, request=oauth_metadata_req)) + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, content=b"{}", request=token_req)) + + +@pytest.mark.anyio +async def test_oauth_401_flow_generator_client_credentials_requires_client_info() -> None: + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost/cb")], + client_name="t", + grant_types=["client_credentials"], + ), + storage=_NoopStorage(), + client_info=None, + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_401 = httpx.Response(401, headers={"WWW-Authenticate": 'Bearer scope="read"'}, request=request) + + flow = oauth_401_flow_generator(provider, request, response_401, initial_prm=_prm(auth_server="https://as.example")) + oauth_metadata_req = await flow.__anext__() + + with pytest.raises(OAuthFlowError): + await flow.asend(_oauth_metadata_response(request=oauth_metadata_req)) + + +@pytest.mark.anyio +async def test_oauth_403_flow_generator_exits_when_error_is_not_insufficient_scope() -> None: + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=_NoopStorage(), + client_info=OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]), + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_403 = httpx.Response(403, headers={"WWW-Authenticate": 'Bearer error="access_denied"'}, request=request) + + flow = oauth_403_flow_generator(provider, request, response_403) + with pytest.raises(StopAsyncIteration): + await flow.__anext__() + + +@pytest.mark.anyio +async def test_oauth_401_flow_generator_skips_oasm_loop_when_discovery_urls_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def build_urls(auth_server_url: str | None, server_url: str) -> list[str]: + return [] + + monkeypatch.setattr(_oauth_401_flow, "build_oauth_authorization_server_metadata_discovery_urls", build_urls) + + ctx = _DummyOAuthContext( + server_url="https://rs.example/mcp", + client_metadata=OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost/cb")], client_name="t"), + storage=_NoopStorage(), + client_info=OAuthClientInformationFull(client_id="cid", redirect_uris=[AnyHttpUrl("http://localhost/cb")]), + ) + provider = _DummyProvider(ctx) + + request = httpx.Request("GET", "https://rs.example/mcp") + response_401 = httpx.Response(401, headers={"WWW-Authenticate": 'Bearer scope="read"'}, request=request) + + flow = oauth_401_flow_generator(provider, request, response_401, initial_prm=_prm(auth_server="https://as.example")) + token_req = await flow.__anext__() + + assert token_req.method == "POST" + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, content=b"{}", request=token_req)) + + +@pytest.mark.anyio +async def test_protocol_stub_bodies_are_executable_for_branch_coverage() -> None: + # _oauth_401_flow._OAuth401FlowProvider Protocol stubs + context_property = getattr(_OAuth401FlowProvider, "context") + assert context_property.fget is not None + context_fget = context_property.fget + assert context_fget(object()) is None + perform_authorization = getattr(_OAuth401FlowProvider, "_perform_authorization") + assert await perform_authorization(object()) is None + handle_token_response = getattr(_OAuth401FlowProvider, "_handle_token_response") + assert await handle_token_response(object(), httpx.Response(200)) is None + + # protocol.py Protocol stubs + get_key_pair = cast(Any, DPoPStorage.get_key_pair) + assert await get_key_pair(object(), "oauth2") is None + set_key_pair = cast(Any, DPoPStorage.set_key_pair) + assert await set_key_pair(object(), "oauth2", object()) is None + + # multi_protocol.py Protocol stubs (single-line "..." bodies are not excluded by coverage config) + get_tokens = cast(Any, _OAuthTokenOnlyStorage.get_tokens) + assert await get_tokens(object()) is None + set_tokens = cast(Any, _OAuthTokenOnlyStorage.set_tokens) + assert await set_tokens(object(), OAuthToken(access_token="at", token_type="Bearer")) is None + + generate_proof = cast(Any, DPoPProofGenerator.generate_proof) + assert generate_proof(object(), "GET", "https://example.com") is None + get_public_key_jwk = cast(Any, DPoPProofGenerator.get_public_key_jwk) + assert get_public_key_jwk(object()) is None + + auth_context = AuthContext(server_url="https://example.com", storage=object(), protocol_id="x") + authenticate = cast(Any, AuthProtocol.authenticate) + assert await authenticate(object(), auth_context) is None + prepare_request = cast(Any, AuthProtocol.prepare_request) + assert prepare_request(object(), httpx.Request("GET", "https://example.com"), object()) is None + validate_credentials = cast(Any, AuthProtocol.validate_credentials) + assert validate_credentials(object(), object()) is None + discover_metadata = cast(Any, AuthProtocol.discover_metadata) + assert await discover_metadata(object(), None) is None + + supports_dpop = cast(Any, DPoPEnabledProtocol.supports_dpop) + assert supports_dpop(object()) is None + get_dpop_proof_generator = cast(Any, DPoPEnabledProtocol.get_dpop_proof_generator) + assert get_dpop_proof_generator(object()) is None + initialize_dpop = cast(Any, DPoPEnabledProtocol.initialize_dpop) + assert await initialize_dpop(object()) is None diff --git a/tests/client/auth/test_utils_authorization_servers_discovery.py b/tests/client/auth/test_utils_authorization_servers_discovery.py new file mode 100644 index 000000000..32d50428e --- /dev/null +++ b/tests/client/auth/test_utils_authorization_servers_discovery.py @@ -0,0 +1,119 @@ +"""Coverage tests for auth discovery utilities.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from mcp.client.auth.utils import ( + build_authorization_servers_discovery_urls, + discover_authorization_servers, + extract_field_from_www_auth, + extract_protocol_preferences_from_www_auth, +) +from mcp.shared.auth import AuthProtocolMetadata, ProtectedResourceMetadata + + +def test_extract_field_from_www_auth_with_auth_scheme_filters_match_group() -> None: + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer error="invalid_token", scope="a b", resource_metadata="https://rs/.well-known/prm"' + ) + }, + ) + assert extract_field_from_www_auth(response, "scope", auth_scheme="Bearer") == "a b" + assert extract_field_from_www_auth(response, "scope", auth_scheme="ApiKey") is None + + +def test_extract_protocol_preferences_skips_invalid_entries() -> None: + response = httpx.Response( + 401, + headers={"WWW-Authenticate": 'Bearer protocol_preferences="oauth2:1,api_key:bad,mutual_tls"'}, + ) + assert extract_protocol_preferences_from_www_auth(response) == {"oauth2": 1} + + +def test_build_authorization_servers_discovery_urls_deduplicates() -> None: + # Double slash path normalizes to root, producing a duplicate root URL. + urls = build_authorization_servers_discovery_urls("https://example.com//") + assert urls == ["https://example.com/.well-known/authorization_servers"] + + +@pytest.mark.anyio +async def test_discover_authorization_servers_handles_parse_error_and_recovers() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(200, content=b"{not-json", request=request) + if request.url.path == "/.well-known/authorization_servers": + return httpx.Response( + 200, + json={ + "protocols": [ + {"protocol_id": "api_key", "protocol_version": "1.0"}, + ] + }, + request=request, + ) + return httpx.Response(404, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + protocols = await discover_authorization_servers("https://rs.example/mcp", client) + + assert [p.protocol_id for p in protocols] == ["api_key"] + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 404 + + +@pytest.mark.anyio +async def test_discover_authorization_servers_returns_empty_when_no_protocols_and_no_prm() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/mcp/.well-known/authorization_servers"): + return httpx.Response(200, json={"protocols": []}, request=request) + if request.url.path == "/.well-known/authorization_servers": + return httpx.Response(200, json={"protocols": []}, request=request) + return httpx.Response(404, request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + protocols = await discover_authorization_servers("https://rs.example/mcp", client) + + assert protocols == [] + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 404 + + +class _RaisingClient(httpx.AsyncClient): + def __init__(self) -> None: + self._calls: int = 0 + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, request=request) + + super().__init__(transport=httpx.MockTransport(handler)) + + async def get(self, url: httpx.URL | str, **kwargs: Any) -> httpx.Response: + self._calls += 1 + if self._calls == 1: + raise RuntimeError("network down") + return await super().get(url, **kwargs) + + +@pytest.mark.anyio +async def test_discover_authorization_servers_falls_back_to_prm_after_request_error() -> None: + prm = ProtectedResourceMetadata.model_validate( + { + "resource": "https://rs.example/mcp", + "authorization_servers": ["https://as.example"], + "mcp_auth_protocols": [ + AuthProtocolMetadata(protocol_id="oauth2", protocol_version="2.0"), + ], + } + ) + async with _RaisingClient() as client: + protocols = await discover_authorization_servers( + "https://rs.example/mcp", + http_client=client, + prm=prm, + ) + assert [p.protocol_id for p in protocols] == ["oauth2"] diff --git a/tests/client/test_multi_protocol_provider.py b/tests/client/test_multi_protocol_provider.py index 18d6d4220..886cece04 100644 --- a/tests/client/test_multi_protocol_provider.py +++ b/tests/client/test_multi_protocol_provider.py @@ -146,6 +146,18 @@ def test_provider_initialize_builds_protocol_index(provider: MultiProtocolAuthPr assert provider._get_protocol("other") is None +@pytest.mark.anyio +async def test_mock_protocol_methods_are_exercised_for_coverage() -> None: + ctx = AuthContext(server_url="https://example.com", storage=object(), protocol_id="test_proto") + proto = _MockProtocol() + creds = await proto.authenticate(ctx) + assert creds.protocol_id == "test_proto" + assert await proto.discover_metadata(None, None, None) is None + + api = _MockApiKeyProtocol(api_key="k") + assert await api.discover_metadata(None, None, None) is None + + @pytest.mark.anyio async def test_get_credentials_returns_none_when_storage_empty( provider: MultiProtocolAuthProvider, @@ -312,6 +324,7 @@ def handler(request: httpx.Request) -> httpx.Response: post_mcp = [req for req in requests if req.method == "POST" and req.url.path == "/mcp"] assert len(post_mcp) >= 2 assert any(req.headers.get("x-api-key") == api_key for req in post_mcp) + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 @pytest.mark.anyio @@ -375,6 +388,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert r.status_code == 401 # We should have attempted discovery, but final response must not be the discovery 404. assert ("GET", "/mcp/.well-known/authorization_servers") in seen + assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 class _OAuthTokenOnlyMockStorage: @@ -413,6 +427,13 @@ async def test_oauth_token_storage_adapter_get_tokens_returns_credentials_when_w assert result.refresh_token == "rt" +@pytest.mark.anyio +async def test_oauth_token_storage_adapter_get_tokens_returns_none_when_wrapped_empty() -> None: + wrapped = _OAuthTokenOnlyMockStorage() + adapter = OAuthTokenStorageAdapter(wrapped) + assert await adapter.get_tokens() is None + + @pytest.mark.anyio async def test_oauth_token_storage_adapter_set_tokens_stores_oauth_token_when_given_credentials() -> None: """OAuthTokenStorageAdapter.set_tokens converts OAuthCredentials to OAuthToken and stores.""" diff --git a/tests/client/test_registry.py b/tests/client/test_registry.py index 9bab8cae0..06723348d 100644 --- a/tests/client/test_registry.py +++ b/tests/client/test_registry.py @@ -148,3 +148,26 @@ def test_select_protocol_preferences_unknown_protocol_gets_high_priority(): preferences={"api_key": 999}, ) assert result in ("oauth2", "api_key") + + +@pytest.mark.anyio +async def test_mock_protocol_method_bodies_are_exercised_for_coverage() -> None: + ctx = AuthContext(server_url="https://example.com", storage=object(), protocol_id="mock") + + proto = _MockAuthProtocol() + creds = await proto.authenticate(ctx) + assert creds.protocol_id == "mock" + req = httpx.Request("GET", "https://example.com") + proto.prepare_request(req, creds) + assert proto.validate_credentials(creds) is True + assert await proto.discover_metadata(None, None, None) is None + + oauth2 = _MockOAuth2Protocol() + oauth2.prepare_request(req, creds) + assert oauth2.validate_credentials(creds) is True + assert await oauth2.discover_metadata(None, None, None) is None + + api_key = _MockApiKeyProtocol() + api_key.prepare_request(req, creds) + assert api_key.validate_credentials(creds) is True + assert await api_key.discover_metadata(None, None, None) is None diff --git a/tests/server/auth/test_bearer_auth_middleware.py b/tests/server/auth/test_bearer_auth_middleware.py new file mode 100644 index 000000000..02605de87 --- /dev/null +++ b/tests/server/auth/test_bearer_auth_middleware.py @@ -0,0 +1,78 @@ +"""Coverage tests for RequireAuthMiddleware WWW-Authenticate fields.""" + +from __future__ import annotations + +import pytest +from starlette.types import Message, Receive, Scope, Send + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, RequireAuthMiddleware +from mcp.server.auth.provider import AccessToken + + +@pytest.mark.anyio +async def test_require_auth_middleware_includes_mcp_extension_fields_in_www_authenticate() -> None: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + middleware = RequireAuthMiddleware( + app=app, + required_scopes=[], + auth_protocols=["oauth2", "api_key"], + default_protocol="oauth2", + protocol_preferences={"oauth2": 10, "api_key": 1}, + ) + + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + async def receive() -> Message: + return {"type": "http.request", "body": b""} + + scope: Scope = {"type": "http", "method": "GET", "path": "/", "headers": []} # no user/auth in scope + await middleware(scope, receive=receive, send=send) + + start = next(m for m in sent if m["type"] == "http.response.start") + headers = dict(start["headers"]) + www = headers[b"www-authenticate"].decode() + + assert 'auth_protocols="oauth2 api_key"' in www + assert 'default_protocol="oauth2"' in www + assert 'protocol_preferences="oauth2:10,api_key:1"' in www + + # Exercise local helpers for test coverage. + await receive() + await app(scope, receive=receive, send=send) + + +@pytest.mark.anyio +async def test_require_auth_middleware_calls_inner_app_when_user_present() -> None: + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + async def receive() -> Message: + return {"type": "http.request", "body": b""} + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + await receive() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + middleware = RequireAuthMiddleware(app=app, required_scopes=[]) + scope: Scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "user": AuthenticatedUser( + AccessToken(token="t", client_id="c", scopes=["read"], expires_at=None), + ), + } + await middleware(scope, receive=receive, send=send) + + start = next(m for m in sent if m["type"] == "http.response.start") + assert start["status"] == 200 diff --git a/tests/server/auth/test_dpop_proof_verifier_coverage.py b/tests/server/auth/test_dpop_proof_verifier_coverage.py new file mode 100644 index 000000000..f5886f34c --- /dev/null +++ b/tests/server/auth/test_dpop_proof_verifier_coverage.py @@ -0,0 +1,186 @@ +"""Coverage tests for server-side DPoPProofVerifier.""" + +from __future__ import annotations + +from typing import Any, cast + +import jwt +import pytest + +from mcp.client.auth.dpop import DPoPKeyPair, DPoPProofGeneratorImpl +from mcp.server.auth.dpop import ( + DPoPNonceStore, + DPoPProofVerifier, + DPoPVerificationError, + InMemoryJTIReplayStore, + _compute_thumbprint, +) + + +@pytest.mark.anyio +async def test_dpop_nonce_store_protocol_stubs_are_executable_for_branch_coverage() -> None: + generate_nonce = cast(Any, DPoPNonceStore.generate_nonce) + assert await generate_nonce(object()) is None + validate_nonce = cast(Any, DPoPNonceStore.validate_nonce) + assert await validate_nonce(object(), "n") is None + + +@pytest.mark.anyio +async def test_in_memory_jti_store_prunes_when_near_capacity(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("mcp.server.auth.dpop.time.time", lambda: 100.0) + + store = InMemoryJTIReplayStore(max_size=10) + for i in range(10): + store._store[f"old-{i}"] = 0.0 # expired + + ok = await store.check_and_store("new", exp_time=200.0) + assert ok is True + assert "new" in store._store + assert all(k == "new" or v > 100.0 for k, v in store._store.items()) + + +def test_compute_thumbprint_rejects_unsupported_kty() -> None: + with pytest.raises(DPoPVerificationError, match="Unsupported kty"): + _compute_thumbprint({"kty": "oct"}) + + +@pytest.mark.anyio +async def test_verify_rejects_malformed_jwt() -> None: + verifier = DPoPProofVerifier() + with pytest.raises(DPoPVerificationError, match="Malformed JWT"): + await verifier.verify("not-a-jwt", "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_invalid_typ() -> None: + key_pair = DPoPKeyPair.generate("ES256") + proof = key_pair.sign_dpop_jwt( + payload={"jti": "j", "htm": "GET", "htu": "https://example.com/x", "iat": 1}, + headers={"typ": "JWT", "alg": "ES256", "jwk": key_pair.public_key_jwk}, + ) + verifier = DPoPProofVerifier() + with pytest.raises(DPoPVerificationError, match="Invalid typ"): + await verifier.verify(proof, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_unsupported_algorithm() -> None: + token = jwt.encode( + {"jti": "j", "htm": "GET", "htu": "https://example.com/x", "iat": 1}, + "secret", + algorithm="HS256", + headers={"typ": "dpop+jwt", "jwk": {"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}}, + ) + verifier = DPoPProofVerifier() + with pytest.raises(DPoPVerificationError, match="Invalid algorithm"): + await verifier.verify(token, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_missing_or_private_jwk() -> None: + key_pair = DPoPKeyPair.generate("ES256") + payload = {"jti": "j", "htm": "GET", "htu": "https://example.com/x", "iat": 1} + + missing_jwk = key_pair.sign_dpop_jwt(payload, headers={"typ": "dpop+jwt", "alg": "ES256"}) + verifier = DPoPProofVerifier() + with pytest.raises(DPoPVerificationError, match="Missing or invalid jwk"): + await verifier.verify(missing_jwk, "GET", "https://example.com/x") + + private_jwk = key_pair.sign_dpop_jwt( + payload, + headers={ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": {**key_pair.public_key_jwk, "d": "private"}, + }, + ) + with pytest.raises(DPoPVerificationError, match="private key"): + await verifier.verify(private_jwk, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_invalid_signature_and_decode_fail() -> None: + key_pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(key_pair) + proof = gen.generate_proof("GET", "https://example.com/x") + + verifier = DPoPProofVerifier() + parts = proof.split(".") + tampered = ".".join([parts[0], parts[1], parts[2][::-1]]) + with pytest.raises(DPoPVerificationError, match="Signature failed"): + await verifier.verify(tampered, "GET", "https://example.com/x") + + from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, generate_private_key + from jwt.api_jws import PyJWS + + private_key = generate_private_key(SECP256R1()) + pair_for_decode_error = DPoPKeyPair(private_key, "ES256") + bad_payload = PyJWS().encode( + payload=b"not-json", + key=private_key, + algorithm="ES256", + headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": pair_for_decode_error.public_key_jwk}, + ) + with pytest.raises(DPoPVerificationError, match="Decode failed"): + await verifier.verify(bad_payload, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_missing_claims_and_invalid_claim_types() -> None: + key_pair = DPoPKeyPair.generate("ES256") + verifier = DPoPProofVerifier() + + missing_jti = key_pair.sign_dpop_jwt( + payload={"htm": "GET", "htu": "https://example.com/x", "iat": 1}, + headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": key_pair.public_key_jwk}, + ) + with pytest.raises(DPoPVerificationError, match="Missing jti"): + await verifier.verify(missing_jti, "GET", "https://example.com/x") + + bad_jti = key_pair.sign_dpop_jwt( + payload={"jti": "", "htm": "GET", "htu": "https://example.com/x", "iat": 1}, + headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": key_pair.public_key_jwk}, + ) + with pytest.raises(DPoPVerificationError, match="Invalid jti"): + await verifier.verify(bad_jti, "GET", "https://example.com/x") + + bad_htu = key_pair.sign_dpop_jwt( + payload={"jti": "j", "htm": "GET", "htu": "", "iat": 1}, + headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": key_pair.public_key_jwk}, + ) + with pytest.raises(DPoPVerificationError, match="Invalid htu"): + await verifier.verify(bad_htu, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_iat_type_and_replay() -> None: + key_pair = DPoPKeyPair.generate("ES256") + verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) + + bad_iat = key_pair.sign_dpop_jwt( + payload={"jti": "j", "htm": "GET", "htu": "https://example.com/x", "iat": "x"}, + headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": key_pair.public_key_jwk}, + ) + with pytest.raises(DPoPVerificationError, match="Invalid iat"): + await verifier.verify(bad_iat, "GET", "https://example.com/x") + + gen = DPoPProofGeneratorImpl(key_pair) + proof = gen.generate_proof("GET", "https://example.com/x") + await verifier.verify(proof, "GET", "https://example.com/x") + with pytest.raises(DPoPVerificationError, match="Replay"): + await verifier.verify(proof, "GET", "https://example.com/x") + + +@pytest.mark.anyio +async def test_verify_rejects_ath_and_jkt_mismatch() -> None: + key_pair = DPoPKeyPair.generate("ES256") + gen = DPoPProofGeneratorImpl(key_pair) + verifier = DPoPProofVerifier() + + proof = gen.generate_proof("GET", "https://example.com/x", credential="token-a") + with pytest.raises(DPoPVerificationError, match="ath mismatch"): + await verifier.verify(proof, "GET", "https://example.com/x", access_token="token-b") + + proof2 = gen.generate_proof("GET", "https://example.com/x") + with pytest.raises(DPoPVerificationError, match="jkt mismatch"): + await verifier.verify(proof2, "GET", "https://example.com/x", expected_jkt="wrong") diff --git a/tests/server/auth/test_token_handler_client_credentials.py b/tests/server/auth/test_token_handler_client_credentials.py new file mode 100644 index 000000000..f61a4affa --- /dev/null +++ b/tests/server/auth/test_token_handler_client_credentials.py @@ -0,0 +1,195 @@ +"""Coverage tests for TokenHandler client_credentials flow.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +from pydantic import AnyHttpUrl +from starlette.requests import Request + +from mcp.server.auth.handlers.token import TokenHandler +from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class _ProviderBase: + def __init__(self, client: OAuthClientInformationFull) -> None: + self._client = client + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self._client if client_id == self._client.client_id else None + + +class _ProviderWithClientCredentials(_ProviderBase): + async def exchange_client_credentials( + self, + client_info: OAuthClientInformationFull, + *, + scopes: list[str], + resource: str | None, + ) -> OAuthToken: + scope_str = " ".join(scopes) if scopes else None + return OAuthToken(access_token="at", token_type="Bearer", expires_in=3600, scope=scope_str) + + +class _ProviderWithoutClientCredentials(_ProviderBase): + pass + + +class _ProviderWithClientCredentialsError(_ProviderBase): + async def exchange_client_credentials( + self, + client_info: OAuthClientInformationFull, + *, + scopes: list[str], + resource: str | None, + ) -> OAuthToken: + raise TokenError(error="invalid_scope", error_description="bad scope") + + +class _ProviderWithClientCredentialsNone(_ProviderBase): + async def exchange_client_credentials( + self, + client_info: OAuthClientInformationFull, + *, + scopes: list[str], + resource: str | None, + ) -> OAuthToken | None: + return None + + +def _make_form_request(body: bytes) -> Request: + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": body, "more_body": False} + + scope: dict[str, Any] = { + "type": "http", + "method": "POST", + "path": "/token", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + return Request(scope, receive) + + +def _client_info(*, grant_types: list[str]) -> OAuthClientInformationFull: + return OAuthClientInformationFull( + client_id="cid", + client_secret="sec", + token_endpoint_auth_method="client_secret_post", + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + grant_types=grant_types, + ) + + +@pytest.mark.anyio +async def test_token_handler_client_credentials_success() -> None: + provider = _ProviderWithClientCredentials(_client_info(grant_types=["client_credentials"])) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec&scope=read") + + response = await handler.handle(request) + + assert response.status_code == 200 + + +@pytest.mark.anyio +async def test_token_handler_client_credentials_unsupported_when_provider_missing_exchange() -> None: + provider = _ProviderWithoutClientCredentials(_client_info(grant_types=["client_credentials"])) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec") + + response = await handler.handle(request) + + assert response.status_code == 400 + + +@pytest.mark.anyio +async def test_token_handler_client_credentials_surfaces_token_error() -> None: + provider = _ProviderWithClientCredentialsError(_client_info(grant_types=["client_credentials"])) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec&scope=bad") + + response = await handler.handle(request) + + assert response.status_code == 400 + + +@pytest.mark.anyio +async def test_token_handler_client_credentials_uses_client_scope_when_request_scope_missing() -> None: + client = OAuthClientInformationFull( + client_id="cid", + client_secret="sec", + token_endpoint_auth_method="client_secret_post", + redirect_uris=[AnyHttpUrl("http://localhost/callback")], + grant_types=["client_credentials"], + scope="read write", + ) + provider = _ProviderWithClientCredentials(client) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec") + + response = await handler.handle(request) + + assert response.status_code == 200 + assert response.body is not None + assert b'"scope":"read write"' in response.body + + +@pytest.mark.anyio +async def test_token_handler_client_credentials_returns_error_when_exchange_returns_none() -> None: + provider = _ProviderWithClientCredentialsNone(_client_info(grant_types=["client_credentials"])) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec") + + response = await handler.handle(request) + + assert response.status_code == 400 + + +@pytest.mark.anyio +async def test_token_handler_falls_through_when_token_request_is_unexpected(monkeypatch: pytest.MonkeyPatch) -> None: + import mcp.server.auth.handlers.token as token_module + + provider = _ProviderWithClientCredentials(_client_info(grant_types=["client_credentials"])) + authenticator = ClientAuthenticator(cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider)) + handler = TokenHandler( + provider=cast(OAuthAuthorizationServerProvider[Any, Any, Any], provider), + client_authenticator=authenticator, + ) + + class _WeirdTokenRequest: + grant_type = "client_credentials" + + def validate_python(_: object) -> _WeirdTokenRequest: + return _WeirdTokenRequest() + + monkeypatch.setattr(token_module.token_request_adapter, "validate_python", validate_python) + + request = _make_form_request(b"grant_type=client_credentials&client_id=cid&client_secret=sec") + response = await handler.handle(request) + + assert response.status_code == 400 diff --git a/tests/server/auth/test_verifiers.py b/tests/server/auth/test_verifiers.py index d3923f2dc..bb296d44c 100644 --- a/tests/server/auth/test_verifiers.py +++ b/tests/server/auth/test_verifiers.py @@ -139,6 +139,14 @@ async def test_api_key_verifier_accepts_bearer_when_key_in_valid_keys() -> None: assert result.token == "mykey" +@pytest.mark.anyio +async def test_api_key_verifier_rejects_bearer_when_key_not_in_valid_keys() -> None: + verifier = APIKeyVerifier(valid_keys={"mykey"}) + request = _request_with_headers([("Authorization", "Bearer other")]) + result = await verifier.verify(request) + assert result is None + + @pytest.mark.anyio async def test_api_key_verifier_rejects_authorization_apikey_scheme() -> None: verifier = APIKeyVerifier(valid_keys={"mykey"}) diff --git a/tests/server/auth/test_verifiers_dpop.py b/tests/server/auth/test_verifiers_dpop.py index 0401b7888..e50d15603 100644 --- a/tests/server/auth/test_verifiers_dpop.py +++ b/tests/server/auth/test_verifiers_dpop.py @@ -43,6 +43,11 @@ def _make_request( return Request(scope) +def test_make_request_accepts_path_without_scheme() -> None: + request = _make_request("GET", "/api/resource", {"Authorization": "Bearer t"}) + assert request.url.path == "/api/resource" + + @pytest.fixture def valid_token() -> AccessToken: return AccessToken( diff --git a/tests/server/test_fastmcp_shim.py b/tests/server/test_fastmcp_shim.py new file mode 100644 index 000000000..1fca182c6 --- /dev/null +++ b/tests/server/test_fastmcp_shim.py @@ -0,0 +1,25 @@ +"""Tests for the FastMCP compatibility shim.""" + +from __future__ import annotations + + +def test_fastmcp_exports() -> None: + from mcp.server.fastmcp import FastMCP, StreamableHTTPASGIApp + + assert FastMCP is not None + assert StreamableHTTPASGIApp is not None + + +def test_fastmcp_wraps_mcpserver_and_tool_decorator() -> None: + from mcp.server.fastmcp import FastMCP + + fast_mcp = FastMCP(name="test", instructions="hi", host="127.0.0.1", port=1234) + assert fast_mcp.host == "127.0.0.1" + assert fast_mcp.port == 1234 + assert getattr(fast_mcp, "_mcp_server", None) is not None + + @fast_mcp.tool() + def hello() -> str: + return "world" + + assert hello() == "world" From ea5910d39a92f6407ec2f8938fb129b66c2bed75 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 22:18:17 +0800 Subject: [PATCH 57/64] fix(auth): remove unnecessary pragma no cover annotations --- src/mcp/client/auth/utils.py | 2 +- tests/client/test_auth.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index bb7f8a658..3b73470e5 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -337,7 +337,7 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth content = await response.aread() asm = OAuthMetadata.model_validate_json(content) return True, asm - except ValidationError: # pragma: no cover + except ValidationError: return True, None elif response.status_code < 400 or response.status_code >= 500: return False, None # Non-4XX error, stop trying diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 750347b6d..0af00fb0a 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -44,7 +44,7 @@ def __init__(self): self._client_info: OAuthClientInformationFull | None = None async def get_tokens(self) -> OAuthToken | None: - return self._tokens # pragma: no cover + return self._tokens async def set_tokens(self, tokens: OAuthToken) -> None: self._tokens = tokens From 87288ebfb84bd43b1cbcba83a14c8a64ca3c7ddb Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 23:16:41 +0800 Subject: [PATCH 58/64] fix(auth): add lax no cover to Protocol stubs for Python 3.14 coverage --- src/mcp/client/auth/_oauth_401_flow.py | 6 +++--- src/mcp/client/auth/multi_protocol.py | 4 ++-- src/mcp/client/auth/protocol.py | 16 ++++++++++++---- src/mcp/server/auth/dpop.py | 5 +++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/mcp/client/auth/_oauth_401_flow.py b/src/mcp/client/auth/_oauth_401_flow.py index da26c024c..cfff4c11a 100644 --- a/src/mcp/client/auth/_oauth_401_flow.py +++ b/src/mcp/client/auth/_oauth_401_flow.py @@ -35,11 +35,11 @@ class _OAuth401FlowProvider(Protocol): """Provider interface for oauth_401_flow_generator (OAuthClientProvider duck type).""" @property - def context(self) -> Any: ... + def context(self) -> Any: ... # pragma: lax no cover - async def _perform_authorization(self) -> httpx.Request: ... + async def _perform_authorization(self) -> httpx.Request: ... # pragma: lax no cover - async def _handle_token_response(self, response: httpx.Response) -> None: ... + async def _handle_token_response(self, response: httpx.Response) -> None: ... # pragma: lax no cover logger = logging.getLogger(__name__) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index 12dcb6a50..f78a0e7cf 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -127,9 +127,9 @@ def _credentials_to_storage(credentials: AuthCredentials) -> AuthCredentials | O class _OAuthTokenOnlyStorage(Protocol): """OAuthToken-only storage contract (wrapped by OAuthTokenStorageAdapter).""" - async def get_tokens(self) -> OAuthToken | None: ... + async def get_tokens(self) -> OAuthToken | None: ... # pragma: lax no cover - async def set_tokens(self, tokens: OAuthToken) -> None: ... + async def set_tokens(self, tokens: OAuthToken) -> None: ... # pragma: lax no cover class OAuthTokenStorageAdapter: diff --git a/src/mcp/client/auth/protocol.py b/src/mcp/client/auth/protocol.py index 29acaf17b..530ed4e12 100644 --- a/src/mcp/client/auth/protocol.py +++ b/src/mcp/client/auth/protocol.py @@ -15,15 +15,23 @@ class DPoPStorage(Protocol): """Storage interface for DPoP key pairs.""" - async def get_key_pair(self, protocol_id: str) -> Any: ... - async def set_key_pair(self, protocol_id: str, key_pair: Any) -> None: ... + async def get_key_pair(self, protocol_id: str) -> Any: ... # pragma: lax no cover + + async def set_key_pair(self, protocol_id: str, key_pair: Any) -> None: ... # pragma: lax no cover class DPoPProofGenerator(Protocol): """DPoP proof generator interface.""" - def generate_proof(self, method: str, uri: str, credential: str | None = None, nonce: str | None = None) -> str: ... - def get_public_key_jwk(self) -> dict[str, Any]: ... + def generate_proof( # pragma: lax no cover + self, + method: str, + uri: str, + credential: str | None = None, + nonce: str | None = None, + ) -> str: ... + + def get_public_key_jwk(self) -> dict[str, Any]: ... # pragma: lax no cover class ClientRegistrationResult(Protocol): diff --git a/src/mcp/server/auth/dpop.py b/src/mcp/server/auth/dpop.py index 2f3e4f9b3..57547e342 100644 --- a/src/mcp/server/auth/dpop.py +++ b/src/mcp/server/auth/dpop.py @@ -56,8 +56,9 @@ async def check_and_store(self, jti: str, exp_time: float) -> bool: class DPoPNonceStore(Protocol): """Protocol for server-managed DPoP nonce (optional feature).""" - async def generate_nonce(self) -> str: ... - async def validate_nonce(self, nonce: str) -> bool: ... + async def generate_nonce(self) -> str: ... # pragma: lax no cover + + async def validate_nonce(self, nonce: str) -> bool: ... # pragma: lax no cover class InMemoryJTIReplayStore: From 9e1a9abc8ee914d3ab46350646bd603ad790980b Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 23:29:31 +0800 Subject: [PATCH 59/64] docs: fix script cmds and unnecessary content --- docs/authorization-multiprotocol.md | 69 +++++++++++++------ examples/README.md | 7 +- .../README.md | 8 +-- .../simple-auth-multiprotocol/README.md | 6 +- 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/docs/authorization-multiprotocol.md b/docs/authorization-multiprotocol.md index 38102464b..775d41f49 100644 --- a/docs/authorization-multiprotocol.md +++ b/docs/authorization-multiprotocol.md @@ -465,39 +465,68 @@ See `RequireAuthMiddleware` and PRM handler in `mcp.server.auth` for how these a ## 4. Integration test examples -### 4.1 Phase 2: Multi-protocol (API Key, OAuth, mTLS placeholder) +### 4.1 Multi-protocol (API Key, OAuth, mTLS placeholder) -**Script:** `./scripts/run_phase2_multiprotocol_integration_test.sh` (from repo root). +**Script:** `./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` -**Behavior:** +**Quick start (from repo root):** -- Starts the multi-protocol resource server (`simple-auth-multiprotocol-rs`) on port 8002 with `--api-keys=demo-api-key-12345`. For OAuth, also starts the AS (`simple-auth-as`) on port 9000. -- Waits for PRM: `GET http://localhost:8002/.well-known/oauth-protected-resource/mcp`. -- Runs the client based on `MCP_AUTH_PROTOCOL`: - - **api_key** (default): `simple-auth-multiprotocol-client` with `MCP_SERVER_URL=http://localhost:8002/mcp` and `MCP_API_KEY=demo-api-key-12345`. No AS is required. - - **oauth**: `simple-auth-client` against the same RS; the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. - - **mutual_tls**: the same multiprotocol client without an API key; mTLS is a placeholder (no real client certificate validation). +```bash +# API Key (non-interactive, default) +./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh + +# OAuth (interactive — complete authorization in browser) +MCP_AUTH_PROTOCOL=oauth ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh + +# Mutual TLS placeholder (expect "not implemented" error) +MCP_AUTH_PROTOCOL=mutual_tls ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh +``` + +The script starts the multi-protocol RS on port 8002 (and AS on 9000 for OAuth), waits for PRM readiness, then runs the client with the selected protocol. For `api_key` and `mutual_tls`, the script is fully automated and prints PASS/FAIL. For `oauth`, the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. + +**Env variables:** `MCP_RS_PORT` (default 8002), `MCP_AS_PORT` (default 9000), `MCP_AUTH_PROTOCOL` (default `api_key`), `MCP_SKIP_OAUTH=1` (skip manual OAuth test). **Demonstrates:** PRM and optional unified discovery, protocol selection (API Key vs OAuth), and API Key authentication without an AS. -### 4.2 Phase 4: DPoP integration +### 4.2 DPoP integration + +**Script:** `./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh` + +**Quick start (from repo root):** + +```bash +# Automated tests only (no browser) +MCP_SKIP_OAUTH=1 ./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh -**Script:** `./scripts/run_phase4_dpop_integration_test.sh` (from repo root). +# Full test including manual OAuth+DPoP (requires browser) +./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh +``` + +The script starts AS on port 9000 and RS on port 8002 with `--dpop-enabled`, then runs automated curl tests: -**Behavior:** +- API Key request → 200 (DPoP does not affect API Key). +- Bearer token without DPoP proof → 401 (RS requires DPoP when token is DPoP-bound). +- Negative: fake token, wrong htm/htu, DPoP without Authorization → 401. -- Starts AS on 9000 and RS on 8002 with `--dpop-enabled` and an API key. -- Runs **automated** curl tests: - - **B2:** API Key request → 200 (DPoP does not affect API Key). - - **A2:** Bearer token without DPoP proof → 401 (RS requires DPoP when token is DPoP-bound). - - Negative: fake token, wrong htm/htu, DPoP without Authorization → 401. -- Optionally runs a **manual** OAuth+DPoP client test: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1` with the multiprotocol client; the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". +When `MCP_SKIP_OAUTH` is not set, the script also runs a manual OAuth+DPoP client test: the user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". -**Env:** `MCP_SKIP_OAUTH=1` skips the manual client step and runs only the automated curl tests. +**Env variables:** `MCP_RS_PORT` (default 8002), `MCP_AS_PORT` (default 9000), `MCP_SKIP_OAUTH=1` (skip manual OAuth+DPoP test). **Demonstrates:** DPoP proof verification on the server, rejection of Bearer tokens without a proof when DPoP is required, and a successful OAuth+DPoP flow with the example client. -### 4.3 Test matrix (reference) +### 4.3 OAuth2 backward compatibility + +**Script:** `./examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh` + +**Quick start (from repo root):** + +```bash +./examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh +``` + +Starts the `simple-auth` AS and RS (OAuth-only, no multi-protocol), then runs `simple-auth-client`. The user completes OAuth in the browser, then runs `list`, `call get_time {}`, `quit`. Verifies that the existing OAuth-only path still works unchanged. + +### 4.4 Test matrix (reference) | Case | Auth type | Expected result | |------|------------------|-----------------| diff --git a/examples/README.md b/examples/README.md index 1b35bb7ee..b1764ae33 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,16 +11,15 @@ for real-world servers. ### API Key - Use `MCP_API_KEY` on the client; start RS with `--api-keys=...` (no AS required). -- One-command test (from repo root): `MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +- One-command test (from repo root): `./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` ### OAuth + DPoP - Start AS and RS with `--dpop-enabled`; client: `MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1`. -- One-command test (from repo root): `./scripts/run_phase4_dpop_integration_test.sh` (use `MCP_SKIP_OAUTH=1` to skip manual OAuth step). +- One-command test (from repo root): `./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh` (use `MCP_SKIP_OAUTH=1` to skip manual OAuth step). ### Mutual TLS (placeholder) -- mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` -- mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` +- mTLS is a placeholder (no client cert validation). Script: `MCP_AUTH_PROTOCOL=mutual_tls ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` **Client**: [simple-auth-multiprotocol-client](clients/simple-auth-multiprotocol-client/) — supports API Key (`MCP_API_KEY`), OAuth+DPoP (`MCP_USE_OAUTH=1`, `MCP_DPOP_ENABLED=1`), and mTLS placeholder. diff --git a/examples/clients/simple-auth-multiprotocol-client/README.md b/examples/clients/simple-auth-multiprotocol-client/README.md index 5e2d95f81..66de5c422 100644 --- a/examples/clients/simple-auth-multiprotocol-client/README.md +++ b/examples/clients/simple-auth-multiprotocol-client/README.md @@ -26,7 +26,7 @@ MCP_SERVER_URL=http://localhost:8002/mcp MCP_API_KEY=demo-api-key-12345 uv run m ``` **One-command test** from repo root: -`MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +`./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` starts the resource server and this client with API Key; at `mcp>` run `list`, `call get_time {}`, `quit`. ## Running with OAuth + DPoP @@ -45,16 +45,16 @@ MCP_SERVER_URL=http://localhost:8002/mcp MCP_USE_OAUTH=1 MCP_DPOP_ENABLED=1 uv r Complete OAuth in the browser; then at `mcp>` run `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". **One-command test** from repo root: -`./scripts/run_phase4_dpop_integration_test.sh` — starts AS and RS with DPoP, then runs this client (OAuth+DPoP). Use `MCP_SKIP_OAUTH=1` to run only the automated curl tests and skip the manual client step. +`./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh` — starts AS and RS with DPoP, then runs this client (OAuth+DPoP). Use `MCP_SKIP_OAUTH=1` to run only the automated curl tests and skip the manual client step. ## Running with Mutual TLS (placeholder) Mutual TLS is a **placeholder** in this example: the client registers the `mutual_tls` protocol but does **not** perform client certificate authentication. Selecting mTLS will show a "not implemented" style message. -- **`MCP_AUTH_PROTOCOL=mutual_tls`** (with the phase2 script) runs this client in mTLS mode; the client will start but mTLS auth is not implemented. +- **`MCP_AUTH_PROTOCOL=mutual_tls`** runs this client in mTLS mode; the client will start but mTLS auth is not implemented. **One-command test** from repo root: -`MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` +`MCP_AUTH_PROTOCOL=mutual_tls ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` ## Commands diff --git a/examples/servers/simple-auth-multiprotocol/README.md b/examples/servers/simple-auth-multiprotocol/README.md index 18cd90bed..e8a2d5d27 100644 --- a/examples/servers/simple-auth-multiprotocol/README.md +++ b/examples/servers/simple-auth-multiprotocol/README.md @@ -38,7 +38,7 @@ You can run the Resource Server **without** the Authorization Server when using 3. At the `mcp>` prompt, run `list`, `call get_time {}`, then `quit`. **One-command verification** (from repo root): -`MCP_AUTH_PROTOCOL=api_key ./scripts/run_phase2_multiprotocol_integration_test.sh` +`./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` This starts the RS, then the client with API Key; complete the session with `list`, `call get_time {}`, `quit`. ## Running with DPoP (OAuth + DPoP) @@ -63,7 +63,7 @@ DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds the access token to a c Complete OAuth in the browser, then at `mcp>` run `list`, `call get_time {}`, `quit`. Server logs should show "Authentication successful with DPoP". **One-command verification** (from repo root): -`./scripts/run_phase4_dpop_integration_test.sh` — starts AS and RS (with `--dpop-enabled`), runs automated DPoP tests, then optionally the OAuth+DPoP client (use `MCP_SKIP_OAUTH=1` to skip the manual OAuth step). +`./examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh` — starts AS and RS (with `--dpop-enabled`), runs automated DPoP tests, then optionally the OAuth+DPoP client (use `MCP_SKIP_OAUTH=1` to skip the manual OAuth step). ## Running with Mutual TLS (placeholder) @@ -71,7 +71,7 @@ Mutual TLS is a **placeholder** in this example: the server accepts the `mutual_ - **Server**: No extra flags; `auth_protocols` already includes `mutual_tls`. - **Client** (from repo root): - `MCP_AUTH_PROTOCOL=mutual_tls ./scripts/run_phase2_multiprotocol_integration_test.sh` + `MCP_AUTH_PROTOCOL=mutual_tls ./examples/clients/simple-auth-multiprotocol-client/run_multiprotocol_test.sh` The client will start but mTLS authentication is not implemented in this example. ## Options From ea9074c0053e3a068acac239ed3ce827653b3bec Mon Sep 17 00:00:00 2001 From: nypdmax Date: Fri, 6 Feb 2026 23:29:35 +0800 Subject: [PATCH 60/64] chore: clean up legacy env var and echo in scripts --- .../mcp_simple_auth_multiprotocol_client/main.py | 2 +- .../clients/simple-auth-multiprotocol-client/run_dpop_test.sh | 2 +- .../simple-auth-multiprotocol-client/run_oauth2_test.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py index f289485bd..66db4e938 100644 --- a/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py +++ b/examples/clients/simple-auth-multiprotocol-client/mcp_simple_auth_multiprotocol_client/main.py @@ -234,7 +234,7 @@ async def redirect_handler(url: str) -> None: print(f"OAuth protocol enabled (DPoP: {self.dpop_enabled})") # Add non-OAuth protocols. Allow forcing protocol injection for integration tests. - forced = os.getenv("MCP_AUTH_PROTOCOL", os.getenv("MCP_PHASE2_PROTOCOL", "")).strip().lower() + forced = os.getenv("MCP_AUTH_PROTOCOL", "").strip().lower() if forced in ("mutual_tls", "mtls"): # Force mTLS placeholder to be selectable (do not inject API key fallback). protocols.append(MutualTlsPlaceholderProtocol()) diff --git a/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh index f2deca9d1..3d3123218 100755 --- a/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_dpop_test.sh @@ -29,7 +29,7 @@ SKIP_OAUTH="${MCP_SKIP_OAUTH:-0}" cd "$REPO_ROOT" echo "============================================================" -echo "Phase 4 DPoP Integration Test" +echo "DPoP Integration Test" echo "============================================================" echo "Repo root: $REPO_ROOT" echo "AS port: $AS_PORT" diff --git a/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh b/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh index 07695816f..dc7e4f6ca 100755 --- a/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh +++ b/examples/clients/simple-auth-multiprotocol-client/run_oauth2_test.sh @@ -58,9 +58,9 @@ cd "$REPO_ROOT" wait_for_url "http://localhost:$AS_PORT/.well-known/oauth-authorization-server" "Authorization Server" wait_for_url "http://localhost:$RS_PORT/.well-known/oauth-protected-resource/mcp" "Resource Server (PRM)" -# Optional: print PRM (Phase 1 backward compat: resource + authorization_servers; mcp_* may appear) +# Optional: print PRM (backward compat: resource + authorization_servers; mcp_* may appear) echo "" -echo "PRM (RFC 9728 + optional Phase 1 fields):" +echo "PRM (RFC 9728):" curl -sS "http://localhost:$RS_PORT/.well-known/oauth-protected-resource/mcp" | head -c 500 echo "" echo "" From 47472533b7c10562cc84371c9176d85e10f084ce Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 7 Feb 2026 18:55:48 +0800 Subject: [PATCH 61/64] refactor(examples): merge multiprotocol server variants into shared module Centralize 5 server variants into a single VariantConfig-driven implementation; reduce variant packages to thin shims. Delete 8 redundant multiprotocol.py/token_verifier.py files. --- .../mcp_simple_auth_multiprotocol/py.typed | 1 + .../mcp_simple_auth_multiprotocol/server.py | 314 +++++++++++++----- .../token_verifier.py | 4 +- .../multiprotocol.py | 100 ------ .../server.py | 250 +------------- .../token_verifier.py | 76 ----- .../multiprotocol.py | 100 ------ .../server.py | 260 +-------------- .../token_verifier.py | 76 ----- .../multiprotocol.py | 104 ------ .../server.py | 243 +------------- .../token_verifier.py | 76 ----- .../multiprotocol.py | 100 ------ .../server.py | 256 +------------- .../token_verifier.py | 76 ----- 15 files changed, 259 insertions(+), 1777 deletions(-) create mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py delete mode 100644 examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed @@ -0,0 +1 @@ + diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py index 3c545a823..bfd6d701e 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/server.py @@ -1,11 +1,18 @@ """MCP Resource Server with multi-protocol auth (OAuth, API Key, Mutual TLS placeholder). Uses MultiProtocolAuthBackend, PRM with auth_protocols, and /.well-known/authorization_servers. + +Supports multiple discovery variants via VariantConfig for testing different client +discovery paths. The default entry point (``main``) uses the "full" variant which +exposes PRM *with* ``mcp_auth_protocols`` and root unified discovery. Other variants +are available as preset constants and consumed by the thin shim packages +(``mcp_simple_auth_multiprotocol_prm_only``, etc.). """ import contextlib import datetime import logging +from dataclasses import dataclass from typing import Any, Literal import click @@ -18,6 +25,7 @@ from starlette.routing import Route from starlette.types import ASGIApp +from mcp.server.auth.handlers.discovery import AuthorizationServersDiscoveryHandler from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from mcp.server.auth.routes import ( @@ -36,6 +44,74 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Variant configuration +# +# Each variant controls which discovery endpoints the server exposes. +# This allows testing every client discovery path with a single codebase. +# +# Variant PRM mcp_auth_protocols root discovery path discovery +# full (default) yes yes no +# prm_only yes no no +# path_only no no yes +# root_only no yes no +# oauth_fallback no no no +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class VariantConfig: + """Controls PRM content and discovery route exposure for each server variant.""" + + name: str + prm_includes_auth_protocols: bool + expose_root_discovery: bool + expose_path_discovery: bool + www_auth_include_protocol_hints: bool + + +VARIANT_FULL = VariantConfig( + name="full", + prm_includes_auth_protocols=True, + expose_root_discovery=True, + expose_path_discovery=False, + www_auth_include_protocol_hints=True, +) +VARIANT_PRM_ONLY = VariantConfig( + name="prm_only", + prm_includes_auth_protocols=True, + expose_root_discovery=False, + expose_path_discovery=False, + www_auth_include_protocol_hints=False, +) +VARIANT_PATH_ONLY = VariantConfig( + name="path_only", + prm_includes_auth_protocols=False, + expose_root_discovery=False, + expose_path_discovery=True, + www_auth_include_protocol_hints=False, +) +VARIANT_ROOT_ONLY = VariantConfig( + name="root_only", + prm_includes_auth_protocols=False, + expose_root_discovery=True, + expose_path_discovery=False, + www_auth_include_protocol_hints=False, +) +VARIANT_OAUTH_FALLBACK = VariantConfig( + name="oauth_fallback", + prm_includes_auth_protocols=False, + expose_root_discovery=False, + expose_path_discovery=False, + www_auth_include_protocol_hints=False, +) + + +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + + class ResourceServerSettings(BaseSettings): """Settings for the multi-protocol MCP Resource Server.""" @@ -84,7 +160,92 @@ def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: return out -def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: +# --------------------------------------------------------------------------- +# Variant helpers: PRM and discovery route injection +# --------------------------------------------------------------------------- + + +def _add_prm_routes( + routes: list[Route], + resource_url: AnyHttpUrl, + auth_settings: AuthSettings, + protocols_metadata: list[AuthProtocolMetadata], + settings: ResourceServerSettings, + variant: VariantConfig, +) -> None: + """Add Protected Resource Metadata routes. + + When the variant advertises protocols via PRM, ``mcp_auth_protocols``, + ``default_protocol``, and ``protocol_preferences`` are included. + Otherwise only RFC 9728 ``authorization_servers`` / ``scopes`` are served. + """ + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) or None + if variant.prm_includes_auth_protocols: + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + auth_protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs, + ) + ) + else: + # Explicit empty list so the PRM JSON includes "mcp_auth_protocols": [] + # rather than omitting the field — signals "no protocols via PRM". + routes.extend( + create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[auth_settings.issuer_url], + scopes_supported=auth_settings.required_scopes, + auth_protocols=[], + default_protocol=None, + protocol_preferences=None, + ) + ) + + +def _add_discovery_routes( + routes: list[Route], + protocols_metadata: list[AuthProtocolMetadata], + settings: ResourceServerSettings, + variant: VariantConfig, +) -> None: + """Add unified discovery routes (root, path-relative, or none) based on variant.""" + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) or None + if variant.expose_root_discovery: + routes.extend( + create_authorization_servers_discovery_routes( + protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs, + ) + ) + if variant.expose_path_discovery: + handler = AuthorizationServersDiscoveryHandler( + protocols=protocols_metadata, + default_protocol=settings.default_protocol, + protocol_preferences=protocol_prefs, + ) + routes.append( + Route( + "/.well-known/authorization_servers/mcp", + endpoint=handler.handle, + methods=["GET", "OPTIONS"], + ) + ) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + + +def create_multiprotocol_resource_server( + settings: ResourceServerSettings, + variant: VariantConfig = VARIANT_FULL, +) -> Starlette: """Create Starlette app with MultiProtocolAuthBackend, PRM, and discovery routes.""" oauth_verifier = IntrospectionTokenVerifier( introspection_endpoint=settings.auth_server_introspection_endpoint, @@ -101,8 +262,10 @@ def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> St adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) fastmcp = FastMCP( - name="MCP Resource Server (multiprotocol)", - instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth", + name=f"MCP Resource Server (multiprotocol, {variant.name})", + instructions=( + f"Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth ({variant.name} discovery)" + ), host=settings.host, port=settings.port, auth=None, @@ -140,40 +303,25 @@ async def get_time() -> dict[str, Any]: resource_metadata_url = build_resource_metadata_url(resource_url) protocols_metadata = _protocol_metadata_list(settings) auth_protocol_ids = [p.protocol_id for p in protocols_metadata] - protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) + protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) or None + www_auth_protocol_ids = auth_protocol_ids if variant.www_auth_include_protocol_hints else None + www_auth_default_protocol = settings.default_protocol if variant.www_auth_include_protocol_hints else None + www_auth_protocol_prefs = protocol_prefs if variant.www_auth_include_protocol_hints else None require_auth = RequireAuthMiddleware( streamable_app, required_scopes=[settings.mcp_scope], resource_metadata_url=resource_metadata_url, - auth_protocols=auth_protocol_ids, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, + auth_protocols=www_auth_protocol_ids, + default_protocol=www_auth_default_protocol, + protocol_preferences=www_auth_protocol_prefs, ) routes: list[Route] = [ - Route( - "/mcp", - endpoint=require_auth, - ), + Route("/mcp", endpoint=require_auth), ] - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[auth_settings.issuer_url], - scopes_supported=auth_settings.required_scopes, - auth_protocols=protocols_metadata, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - ) - routes.extend( - create_authorization_servers_discovery_routes( - protocols=protocols_metadata, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - ) + _add_prm_routes(routes, resource_url, auth_settings, protocols_metadata, settings, variant) + _add_discovery_routes(routes, protocols_metadata, settings, variant) middleware = [ Middleware(AuthenticationMiddleware, backend=adapter), @@ -193,52 +341,70 @@ async def lifespan(app: Starlette): ) -@click.command() -@click.option("--port", default=8002, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol", -) -@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") -@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") -@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") -def main( - port: int, - auth_server: str, - transport: Literal["sse", "streamable-http"], - oauth_strict: bool, - api_keys: str, - dpop_enabled: bool, -) -> int: - """Run the multi-protocol MCP Resource Server.""" - logging.basicConfig(level=logging.INFO) - try: - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=AnyHttpUrl(auth_server), - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - api_key_valid_keys=api_keys, - dpop_enabled=dpop_enabled, - ) - except ValueError as e: - logger.error("Configuration error: %s", e) - return 1 - - app = create_multiprotocol_resource_server(settings) - logger.info("Multi-protocol RS running on %s", settings.server_url) - logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") - if dpop_enabled: - logger.info("DPoP: enabled (RFC 9449)") - uvicorn.run(app, host=settings.host, port=settings.port) - return 0 +# --------------------------------------------------------------------------- +# CLI entry points +# --------------------------------------------------------------------------- + + +def main_for_variant(variant: VariantConfig) -> click.Command: + """Create a click CLI command for a specific discovery variant. + + Used by the default entry point (``main``) and by the thin shim packages + (e.g. ``mcp_simple_auth_multiprotocol_prm_only.server``). + """ + + @click.command() + @click.option("--port", default=8002, help="Port to listen on") + @click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") + @click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol", + ) + @click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") + @click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") + @click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") + def cli( + port: int, + auth_server: str, + transport: Literal["sse", "streamable-http"], + oauth_strict: bool, + api_keys: str, + dpop_enabled: bool, + ) -> int: + """Run the multi-protocol MCP Resource Server.""" + logging.basicConfig(level=logging.INFO) + try: + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=AnyHttpUrl(auth_server), + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + api_key_valid_keys=api_keys, + dpop_enabled=dpop_enabled, + ) + except ValueError as e: + logger.error("Configuration error: %s", e) + return 1 + + app = create_multiprotocol_resource_server(settings, variant) + logger.info("Multi-protocol RS (%s) running on %s", variant.name, settings.server_url) + logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") + if settings.dpop_enabled: + logger.info("DPoP: enabled (RFC 9449)") + uvicorn.run(app, host=settings.host, port=settings.port) + return 0 + + return cli + + +# Default entry point: full variant (PRM + root discovery) +main = main_for_variant(VARIANT_FULL) if __name__ == "__main__": diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py index 2fbfb6853..33f5e8896 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/token_verifier.py @@ -3,6 +3,8 @@ import logging from typing import Any, cast +import httpx + from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url @@ -25,8 +27,6 @@ def __init__( async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" - import httpx - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): logger.warning("Rejecting unsafe introspection endpoint") return None diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py deleted file mode 100644 index 7b742c62b..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/multiprotocol.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Multi-protocol auth adapter for OAuth-fallback discovery variant.""" - -import logging -import time -from typing import Any, cast - -from starlette.authentication import AuthCredentials, AuthenticationBackend -from starlette.requests import HTTPConnection, Request - -from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken -from mcp.server.auth.verifiers import ( - APIKeyVerifier, - CredentialVerifier, - MultiProtocolAuthBackend, - OAuthTokenVerifier, -) - -logger = logging.getLogger(__name__) - - -class MutualTLSVerifier: - """Placeholder verifier for Mutual TLS.""" - - async def verify( - self, - request: Any, - dpop_verifier: Any = None, - ) -> AccessToken | None: - return None - - -def build_multiprotocol_backend( - oauth_token_verifier: Any, - api_key_valid_keys: set[str], - api_key_scopes: list[str] | None = None, - dpop_enabled: bool = False, -) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: - """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" - oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) - api_key_verifier = APIKeyVerifier( - valid_keys=api_key_valid_keys, - scopes=api_key_scopes or [], - ) - mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) - - dpop_verifier: DPoPProofVerifier | None = None - if dpop_enabled: - dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) - - return backend, dpop_verifier - - -class MultiProtocolAuthBackendAdapter(AuthenticationBackend): - """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" - - def __init__( - self, - backend: MultiProtocolAuthBackend, - dpop_verifier: DPoPProofVerifier | None = None, - ) -> None: - self._backend = backend - self._dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - request = cast(Request, conn) - - dpop_header = request.headers.get("dpop") - if self._dpop_verifier is not None: - if dpop_header: - logger.info("DPoP proof present, verification enabled") - else: - logger.debug("DPoP verification enabled but no DPoP header in request") - elif dpop_header: - logger.debug("DPoP header present but verification not enabled (ignoring)") - - result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) - - if result is None: - if dpop_header and self._dpop_verifier is not None: - logger.warning("Authentication failed (DPoP proof may be invalid)") - else: - logger.debug("Authentication failed (no valid credentials)") - return None - - if result.expires_at is not None and result.expires_at < int(time.time()): - logger.warning("Token expired for client_id=%s", result.client_id) - return None - - if dpop_header and self._dpop_verifier is not None: - logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) - else: - logger.info("Authentication successful (client_id=%s)", result.client_id) - - return ( - AuthCredentials(result.scopes or []), - AuthenticatedUser(result), - ) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py index b2dc32831..7960504b9 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/server.py @@ -1,250 +1,8 @@ -"""MCP Resource Server with multi-protocol auth (OAuth-fallback discovery variant). +"""MCP Resource Server (multiprotocol, OAuth-fallback discovery variant). -This variant: -- PRM does NOT include mcp_auth_protocols (only authorization_servers) -- Does NOT expose any unified discovery endpoints -- Forces clients to use OAuth fallback from PRM.authorization_servers +Thin shim — see mcp_simple_auth_multiprotocol.server for the canonical implementation. """ -import contextlib -import datetime -import logging -from typing import Any, Literal +from mcp_simple_auth_multiprotocol.server import VARIANT_OAUTH_FALLBACK, main_for_variant -import click -import uvicorn -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Route -from starlette.types import ASGIApp - -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from mcp.server.auth.routes import ( - build_resource_metadata_url, - create_protected_resource_routes, -) -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.shared.auth import AuthProtocolMetadata - -from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the multi-protocol MCP Resource Server (OAuth-fallback discovery).""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - host: str = "localhost" - port: int = 8002 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - mcp_scope: str = "user" - oauth_strict: bool = False - api_key_valid_keys: str = "demo-api-key-12345" - default_protocol: str = "oauth2" - protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" - dpop_enabled: bool = False - - -def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: - """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" - auth_base = str(settings.auth_server_url).rstrip("/") - oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") - return [ - AuthProtocolMetadata( - protocol_id="oauth2", - protocol_version="2.0", - metadata_url=oauth_metadata_url, - scopes_supported=[settings.mcp_scope], - ), - AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), - AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), - ] - - -def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: - """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" - out: dict[str, int] = {} - for part in prefs_str.split(","): - s = part.strip() - if ":" in s: - proto, prio = s.split(":", 1) - try: - out[proto.strip()] = int(prio.strip()) - except ValueError: - pass - return out - - -def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: - """Create Starlette app with MultiProtocolAuthBackend and PRM-only (no mcp_auth_protocols, no unified discovery).""" - oauth_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, - ) - api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend, dpop_verifier = build_multiprotocol_backend( - oauth_verifier, - api_key_keys, - api_key_scopes=[settings.mcp_scope], - dpop_enabled=settings.dpop_enabled, - ) - adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) - - fastmcp = FastMCP( - name="MCP Resource Server (multiprotocol, OAuth-fallback discovery)", - instructions=( - "Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (OAuth-fallback discovery)" - ), - host=settings.host, - port=settings.port, - auth=None, - ) - - @fastmcp.tool() - async def get_time() -> dict[str, Any]: - """Return current server time (requires auth).""" - now = datetime.datetime.now() - return { - "current_time": now.isoformat(), - "timezone": "UTC", - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - mcp_server = getattr(fastmcp, "_mcp_server") - session_manager = StreamableHTTPSessionManager( - app=mcp_server, - event_store=None, - retry_interval=None, - json_response=False, - stateless=False, - security_settings=None, - ) - streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) - - auth_settings = AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ) - resource_url = auth_settings.resource_server_url - assert resource_url is not None - resource_metadata_url = build_resource_metadata_url(resource_url) - # We still define full protocol metadata for logging/reference, but PRM will not include mcp_auth_protocols - protocols_metadata = _protocol_metadata_list(settings) - auth_protocol_ids = [p.protocol_id for p in protocols_metadata] - protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) - - require_auth = RequireAuthMiddleware( - streamable_app, - required_scopes=[settings.mcp_scope], - resource_metadata_url=resource_metadata_url, - auth_protocols=auth_protocol_ids, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - - routes: list[Route] = [ - Route( - "/mcp", - endpoint=require_auth, - ), - ] - # PRM without mcp_auth_protocols: only authorization_servers/scopes - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[auth_settings.issuer_url], - scopes_supported=auth_settings.required_scopes, - # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat - # validator auto-filling mcp_auth_protocols from authorization_servers. - auth_protocols=[], - default_protocol=None, - protocol_preferences=None, - ) - ) - - # NOTE: OAuth-fallback variant intentionally does NOT add any unified discovery routes: - # - No /.well-known/authorization_servers - # - No /.well-known/authorization_servers/mcp - - middleware = [ - Middleware(AuthenticationMiddleware, backend=adapter), - Middleware(AuthContextMiddleware), - ] - - @contextlib.asynccontextmanager - async def lifespan(app: Starlette): - async with session_manager.run(): - yield - - return Starlette( - debug=True, - routes=routes, - middleware=middleware, - lifespan=lifespan, - ) - - -@click.command() -@click.option("--port", default=8002, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol", -) -@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") -@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") -@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") -def main( - port: int, - auth_server: str, - transport: Literal["sse", "streamable-http"], - oauth_strict: bool, - api_keys: str, - dpop_enabled: bool, -) -> int: - """Run the multi-protocol MCP Resource Server (OAuth-fallback discovery).""" - logging.basicConfig(level=logging.INFO) - try: - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=AnyHttpUrl(auth_server), - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - api_key_valid_keys=api_keys, - dpop_enabled=dpop_enabled, - ) - except ValueError as e: - logger.error("Configuration error: %s", e) - return 1 - - app = create_multiprotocol_resource_server(settings) - logger.info("Multi-protocol RS (OAuth-fallback discovery) running on %s", settings.server_url) - logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") - if dpop_enabled: - logger.info("DPoP: enabled (RFC 9449)") - uvicorn.run(app, host=settings.host, port=settings.port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +main = main_for_variant(VARIANT_OAUTH_FALLBACK) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py deleted file mode 100644 index e34830263..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_oauth_fallback/token_verifier.py +++ /dev/null @@ -1,76 +0,0 @@ -"""OAuth token verifier using introspection (OAuth-fallback variant reuses same logic).""" - -import logging -from typing import Any, cast - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning("Rejecting unsafe introspection endpoint") - return None - - timeout = httpx.Timeout(10.0, connect=5.0) - async with httpx.AsyncClient(timeout=timeout, verify=True) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - return None - data = response.json() - if not data.get("active", False): - return None - if self.validate_resource and not self._validate_resource(data): - return None - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), - ) - except Exception as e: - logger.warning("Token introspection failed: %s", e) - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - if not self.server_url or not self.resource_url: - return False - aud = token_data.get("aud") - if isinstance(aud, list): - for item in cast(list[str], aud): - if self._is_valid_resource(item): - return True - return False - if isinstance(aud, str): - return self._is_valid_resource(aud) - return False - - def _is_valid_resource(self, resource: str) -> bool: - if not self.resource_url: - return False - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py deleted file mode 100644 index 0cc9bc11d..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/multiprotocol.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Multi-protocol auth adapter for path-only unified discovery variant.""" - -import logging -import time -from typing import Any, cast - -from starlette.authentication import AuthCredentials, AuthenticationBackend -from starlette.requests import HTTPConnection, Request - -from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken -from mcp.server.auth.verifiers import ( - APIKeyVerifier, - CredentialVerifier, - MultiProtocolAuthBackend, - OAuthTokenVerifier, -) - -logger = logging.getLogger(__name__) - - -class MutualTLSVerifier: - """Placeholder verifier for Mutual TLS.""" - - async def verify( - self, - request: Any, - dpop_verifier: Any = None, - ) -> AccessToken | None: - return None - - -def build_multiprotocol_backend( - oauth_token_verifier: Any, - api_key_valid_keys: set[str], - api_key_scopes: list[str] | None = None, - dpop_enabled: bool = False, -) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: - """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" - oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) - api_key_verifier = APIKeyVerifier( - valid_keys=api_key_valid_keys, - scopes=api_key_scopes or [], - ) - mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) - - dpop_verifier: DPoPProofVerifier | None = None - if dpop_enabled: - dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) - - return backend, dpop_verifier - - -class MultiProtocolAuthBackendAdapter(AuthenticationBackend): - """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" - - def __init__( - self, - backend: MultiProtocolAuthBackend, - dpop_verifier: DPoPProofVerifier | None = None, - ) -> None: - self._backend = backend - self._dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - request = cast(Request, conn) - - dpop_header = request.headers.get("dpop") - if self._dpop_verifier is not None: - if dpop_header: - logger.info("DPoP proof present, verification enabled") - else: - logger.debug("DPoP verification enabled but no DPoP header in request") - elif dpop_header: - logger.debug("DPoP header present but verification not enabled (ignoring)") - - result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) - - if result is None: - if dpop_header and self._dpop_verifier is not None: - logger.warning("Authentication failed (DPoP proof may be invalid)") - else: - logger.debug("Authentication failed (no valid credentials)") - return None - - if result.expires_at is not None and result.expires_at < int(time.time()): - logger.warning("Token expired for client_id=%s", result.client_id) - return None - - if dpop_header and self._dpop_verifier is not None: - logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) - else: - logger.info("Authentication successful (client_id=%s)", result.client_id) - - return ( - AuthCredentials(result.scopes or []), - AuthenticatedUser(result), - ) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py index 2fc7bac37..ba1e00084 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/server.py @@ -1,260 +1,8 @@ -"""MCP Resource Server with multi-protocol auth (path-only unified discovery variant). +"""MCP Resource Server (multiprotocol, path-only unified discovery variant). -This variant: -- PRM does NOT include mcp_auth_protocols (only authorization_servers) -- Exposes only path-relative unified discovery endpoint: /.well-known/authorization_servers/mcp +Thin shim — see mcp_simple_auth_multiprotocol.server for the canonical implementation. """ -import contextlib -import datetime -import logging -from typing import Any, Literal +from mcp_simple_auth_multiprotocol.server import VARIANT_PATH_ONLY, main_for_variant -import click -import uvicorn -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Route -from starlette.types import ASGIApp - -from mcp.server.auth.handlers.discovery import AuthorizationServersDiscoveryHandler -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from mcp.server.auth.routes import ( - build_resource_metadata_url, - create_protected_resource_routes, -) -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.shared.auth import AuthProtocolMetadata - -from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the multi-protocol MCP Resource Server (path-only discovery).""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - host: str = "localhost" - port: int = 8002 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - mcp_scope: str = "user" - oauth_strict: bool = False - api_key_valid_keys: str = "demo-api-key-12345" - default_protocol: str = "oauth2" - protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" - dpop_enabled: bool = False - - -def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: - """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" - auth_base = str(settings.auth_server_url).rstrip("/") - oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") - return [ - AuthProtocolMetadata( - protocol_id="oauth2", - protocol_version="2.0", - metadata_url=oauth_metadata_url, - scopes_supported=[settings.mcp_scope], - ), - AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), - AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), - ] - - -def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: - """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" - out: dict[str, int] = {} - for part in prefs_str.split(","): - s = part.strip() - if ":" in s: - proto, prio = s.split(":", 1) - try: - out[proto.strip()] = int(prio.strip()) - except ValueError: - pass - return out - - -def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: - """Create Starlette app with MultiProtocolAuthBackend, PRM (no mcp_auth_protocols) and path-only discovery.""" - oauth_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, - ) - api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend, dpop_verifier = build_multiprotocol_backend( - oauth_verifier, - api_key_keys, - api_key_scopes=[settings.mcp_scope], - dpop_enabled=settings.dpop_enabled, - ) - adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) - - fastmcp = FastMCP( - name="MCP Resource Server (multiprotocol, path-only discovery)", - instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (path-only discovery)", - host=settings.host, - port=settings.port, - auth=None, - ) - - @fastmcp.tool() - async def get_time() -> dict[str, Any]: - """Return current server time (requires auth).""" - now = datetime.datetime.now() - return { - "current_time": now.isoformat(), - "timezone": "UTC", - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - mcp_server = getattr(fastmcp, "_mcp_server") - session_manager = StreamableHTTPSessionManager( - app=mcp_server, - event_store=None, - retry_interval=None, - json_response=False, - stateless=False, - security_settings=None, - ) - streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) - - auth_settings = AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ) - resource_url = auth_settings.resource_server_url - assert resource_url is not None - resource_metadata_url = build_resource_metadata_url(resource_url) - protocols_metadata = _protocol_metadata_list(settings) - auth_protocol_ids = [p.protocol_id for p in protocols_metadata] - protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) - - require_auth = RequireAuthMiddleware( - streamable_app, - required_scopes=[settings.mcp_scope], - resource_metadata_url=resource_metadata_url, - auth_protocols=auth_protocol_ids, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - - routes: list[Route] = [ - Route( - "/mcp", - endpoint=require_auth, - ), - ] - # PRM without mcp_auth_protocols: only authorization_servers/scopes - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[auth_settings.issuer_url], - scopes_supported=auth_settings.required_scopes, - # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat - # validator auto-filling mcp_auth_protocols from authorization_servers. - auth_protocols=[], - default_protocol=None, - protocol_preferences=None, - ) - ) - - # Path-relative unified discovery endpoint (Way B: /.well-known/authorization_servers/mcp) - path_relative_handler = AuthorizationServersDiscoveryHandler( - protocols=protocols_metadata, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - routes.append( - Route( - "/.well-known/authorization_servers/mcp", - endpoint=path_relative_handler.handle, - methods=["GET", "OPTIONS"], - ) - ) - - # NOTE: path-only variant intentionally does NOT add root-based unified discovery - # (no /.well-known/authorization_servers route) - - middleware = [ - Middleware(AuthenticationMiddleware, backend=adapter), - Middleware(AuthContextMiddleware), - ] - - @contextlib.asynccontextmanager - async def lifespan(app: Starlette): - async with session_manager.run(): - yield - - return Starlette( - debug=True, - routes=routes, - middleware=middleware, - lifespan=lifespan, - ) - - -@click.command() -@click.option("--port", default=8002, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol", -) -@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") -@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") -@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") -def main( - port: int, - auth_server: str, - transport: Literal["sse", "streamable-http"], - oauth_strict: bool, - api_keys: str, - dpop_enabled: bool, -) -> int: - """Run the multi-protocol MCP Resource Server (path-only discovery).""" - logging.basicConfig(level=logging.INFO) - try: - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=AnyHttpUrl(auth_server), - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - api_key_valid_keys=api_keys, - dpop_enabled=dpop_enabled, - ) - except ValueError as e: - logger.error("Configuration error: %s", e) - return 1 - - app = create_multiprotocol_resource_server(settings) - logger.info("Multi-protocol RS (path-only discovery) running on %s", settings.server_url) - logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") - if dpop_enabled: - logger.info("DPoP: enabled (RFC 9449)") - uvicorn.run(app, host=settings.host, port=settings.port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +main = main_for_variant(VARIANT_PATH_ONLY) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py deleted file mode 100644 index 9191ef45c..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_path_only/token_verifier.py +++ /dev/null @@ -1,76 +0,0 @@ -"""OAuth token verifier using introspection (path-only variant reuses same logic).""" - -import logging -from typing import Any, cast - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning("Rejecting unsafe introspection endpoint") - return None - - timeout = httpx.Timeout(10.0, connect=5.0) - async with httpx.AsyncClient(timeout=timeout, verify=True) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - return None - data = response.json() - if not data.get("active", False): - return None - if self.validate_resource and not self._validate_resource(data): - return None - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), - ) - except Exception as e: - logger.warning("Token introspection failed: %s", e) - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - if not self.server_url or not self.resource_url: - return False - aud = token_data.get("aud") - if isinstance(aud, list): - for item in cast(list[str], aud): - if self._is_valid_resource(item): - return True - return False - if isinstance(aud, str): - return self._is_valid_resource(aud) - return False - - def _is_valid_resource(self, resource: str) -> bool: - if not self.resource_url: - return False - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py deleted file mode 100644 index c983eb397..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/multiprotocol.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Multi-protocol auth adapter for PRM-only discovery variant.""" - -import logging -import time -from typing import Any, cast - -from starlette.authentication import AuthCredentials, AuthenticationBackend -from starlette.requests import HTTPConnection, Request - -from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken -from mcp.server.auth.verifiers import ( - APIKeyVerifier, - CredentialVerifier, - MultiProtocolAuthBackend, - OAuthTokenVerifier, -) - -logger = logging.getLogger(__name__) - - -class MutualTLSVerifier: - """Placeholder verifier for Mutual TLS. - - Does not validate client certificates; returns None. Real mTLS validation - would inspect the TLS connection for client certificate and verify it. - """ - - async def verify( - self, - request: Any, - dpop_verifier: Any = None, - ) -> AccessToken | None: - return None - - -def build_multiprotocol_backend( - oauth_token_verifier: Any, - api_key_valid_keys: set[str], - api_key_scopes: list[str] | None = None, - dpop_enabled: bool = False, -) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: - """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" - oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) - api_key_verifier = APIKeyVerifier( - valid_keys=api_key_valid_keys, - scopes=api_key_scopes or [], - ) - mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) - - dpop_verifier: DPoPProofVerifier | None = None - if dpop_enabled: - dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) - - return backend, dpop_verifier - - -class MultiProtocolAuthBackendAdapter(AuthenticationBackend): - """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" - - def __init__( - self, - backend: MultiProtocolAuthBackend, - dpop_verifier: DPoPProofVerifier | None = None, - ) -> None: - self._backend = backend - self._dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - request = cast(Request, conn) - - dpop_header = request.headers.get("dpop") - if self._dpop_verifier is not None: - if dpop_header: - logger.info("DPoP proof present, verification enabled") - else: - logger.debug("DPoP verification enabled but no DPoP header in request") - elif dpop_header: - logger.debug("DPoP header present but verification not enabled (ignoring)") - - result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) - - if result is None: - if dpop_header and self._dpop_verifier is not None: - logger.warning("Authentication failed (DPoP proof may be invalid)") - else: - logger.debug("Authentication failed (no valid credentials)") - return None - - if result.expires_at is not None and result.expires_at < int(time.time()): - logger.warning("Token expired for client_id=%s", result.client_id) - return None - - if dpop_header and self._dpop_verifier is not None: - logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) - else: - logger.info("Authentication successful (client_id=%s)", result.client_id) - - return ( - AuthCredentials(result.scopes or []), - AuthenticatedUser(result), - ) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py index c6ccf0440..5af766858 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/server.py @@ -1,243 +1,8 @@ -"""MCP Resource Server with multi-protocol auth (PRM-only discovery variant). +"""MCP Resource Server (multiprotocol, PRM-only discovery variant). -This variant: -- Exposes PRM with mcp_auth_protocols and authorization_servers -- Does NOT expose unified discovery endpoints (authorization_servers, authorization_servers/mcp) +Thin shim — see mcp_simple_auth_multiprotocol.server for the canonical implementation. """ -import contextlib -import datetime -import logging -from typing import Any, Literal +from mcp_simple_auth_multiprotocol.server import VARIANT_PRM_ONLY, main_for_variant -import click -import uvicorn -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Route -from starlette.types import ASGIApp - -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from mcp.server.auth.routes import ( - build_resource_metadata_url, - create_protected_resource_routes, -) -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.shared.auth import AuthProtocolMetadata - -from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the multi-protocol MCP Resource Server (PRM-only discovery).""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - host: str = "localhost" - port: int = 8002 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - mcp_scope: str = "user" - oauth_strict: bool = False - api_key_valid_keys: str = "demo-api-key-12345" - default_protocol: str = "oauth2" - protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" - dpop_enabled: bool = False - - -def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: - """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" - auth_base = str(settings.auth_server_url).rstrip("/") - oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") - return [ - AuthProtocolMetadata( - protocol_id="oauth2", - protocol_version="2.0", - metadata_url=oauth_metadata_url, - scopes_supported=[settings.mcp_scope], - ), - AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), - AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), - ] - - -def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: - """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" - out: dict[str, int] = {} - for part in prefs_str.split(","): - s = part.strip() - if ":" in s: - proto, prio = s.split(":", 1) - try: - out[proto.strip()] = int(prio.strip()) - except ValueError: - pass - return out - - -def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: - """Create Starlette app with MultiProtocolAuthBackend and PRM routes only.""" - oauth_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, - ) - api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend, dpop_verifier = build_multiprotocol_backend( - oauth_verifier, - api_key_keys, - api_key_scopes=[settings.mcp_scope], - dpop_enabled=settings.dpop_enabled, - ) - adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) - - fastmcp = FastMCP( - name="MCP Resource Server (multiprotocol, PRM-only)", - instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (PRM-only discovery)", - host=settings.host, - port=settings.port, - auth=None, - ) - - @fastmcp.tool() - async def get_time() -> dict[str, Any]: - """Return current server time (requires auth).""" - now = datetime.datetime.now() - return { - "current_time": now.isoformat(), - "timezone": "UTC", - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - mcp_server = getattr(fastmcp, "_mcp_server") - session_manager = StreamableHTTPSessionManager( - app=mcp_server, - event_store=None, - retry_interval=None, - json_response=False, - stateless=False, - security_settings=None, - ) - streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) - - auth_settings = AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ) - resource_url = auth_settings.resource_server_url - assert resource_url is not None - resource_metadata_url = build_resource_metadata_url(resource_url) - protocols_metadata = _protocol_metadata_list(settings) - auth_protocol_ids = [p.protocol_id for p in protocols_metadata] - protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) - - require_auth = RequireAuthMiddleware( - streamable_app, - required_scopes=[settings.mcp_scope], - resource_metadata_url=resource_metadata_url, - auth_protocols=auth_protocol_ids, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - - routes: list[Route] = [ - Route( - "/mcp", - endpoint=require_auth, - ), - ] - # PRM with mcp_auth_protocols and authorization_servers - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[auth_settings.issuer_url], - scopes_supported=auth_settings.required_scopes, - auth_protocols=protocols_metadata, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - ) - # NOTE: PRM-only variant intentionally does NOT add unified discovery routes: - # - No root-based /.well-known/authorization_servers - # - No path-relative /.well-known/authorization_servers/mcp - - middleware = [ - Middleware(AuthenticationMiddleware, backend=adapter), - Middleware(AuthContextMiddleware), - ] - - @contextlib.asynccontextmanager - async def lifespan(app: Starlette): - async with session_manager.run(): - yield - - return Starlette( - debug=True, - routes=routes, - middleware=middleware, - lifespan=lifespan, - ) - - -@click.command() -@click.option("--port", default=8002, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol", -) -@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") -@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") -@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") -def main( - port: int, - auth_server: str, - transport: Literal["sse", "streamable-http"], - oauth_strict: bool, - api_keys: str, - dpop_enabled: bool, -) -> int: - """Run the multi-protocol MCP Resource Server (PRM-only discovery).""" - logging.basicConfig(level=logging.INFO) - try: - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=AnyHttpUrl(auth_server), - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - api_key_valid_keys=api_keys, - dpop_enabled=dpop_enabled, - ) - except ValueError as e: - logger.error("Configuration error: %s", e) - return 1 - - app = create_multiprotocol_resource_server(settings) - logger.info("Multi-protocol RS (PRM-only) running on %s", settings.server_url) - logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") - if dpop_enabled: - logger.info("DPoP: enabled (RFC 9449)") - uvicorn.run(app, host=settings.host, port=settings.port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +main = main_for_variant(VARIANT_PRM_ONLY) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py deleted file mode 100644 index fa60c5b15..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_prm_only/token_verifier.py +++ /dev/null @@ -1,76 +0,0 @@ -"""OAuth token verifier using introspection (PRM-only variant reuses same logic).""" - -import logging -from typing import Any, cast - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning("Rejecting unsafe introspection endpoint") - return None - - timeout = httpx.Timeout(10.0, connect=5.0) - async with httpx.AsyncClient(timeout=timeout, verify=True) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - return None - data = response.json() - if not data.get("active", False): - return None - if self.validate_resource and not self._validate_resource(data): - return None - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), - ) - except Exception as e: - logger.warning("Token introspection failed: %s", e) - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - if not self.server_url or not self.resource_url: - return False - aud = token_data.get("aud") - if isinstance(aud, list): - for item in cast(list[str], aud): - if self._is_valid_resource(item): - return True - return False - if isinstance(aud, str): - return self._is_valid_resource(aud) - return False - - def _is_valid_resource(self, resource: str) -> bool: - if not self.resource_url: - return False - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py deleted file mode 100644 index 473ab3765..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/multiprotocol.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Multi-protocol auth adapter for root-only unified discovery variant.""" - -import logging -import time -from typing import Any, cast - -from starlette.authentication import AuthCredentials, AuthenticationBackend -from starlette.requests import HTTPConnection, Request - -from mcp.server.auth.dpop import DPoPProofVerifier, InMemoryJTIReplayStore -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken -from mcp.server.auth.verifiers import ( - APIKeyVerifier, - CredentialVerifier, - MultiProtocolAuthBackend, - OAuthTokenVerifier, -) - -logger = logging.getLogger(__name__) - - -class MutualTLSVerifier: - """Placeholder verifier for Mutual TLS.""" - - async def verify( - self, - request: Any, - dpop_verifier: Any = None, - ) -> AccessToken | None: - return None - - -def build_multiprotocol_backend( - oauth_token_verifier: Any, - api_key_valid_keys: set[str], - api_key_scopes: list[str] | None = None, - dpop_enabled: bool = False, -) -> tuple[MultiProtocolAuthBackend, DPoPProofVerifier | None]: - """Build MultiProtocolAuthBackend with OAuth, API Key, and mTLS (placeholder) verifiers.""" - oauth_verifier = OAuthTokenVerifier(oauth_token_verifier) - api_key_verifier = APIKeyVerifier( - valid_keys=api_key_valid_keys, - scopes=api_key_scopes or [], - ) - mtls_verifier: CredentialVerifier = MutualTLSVerifier() - backend = MultiProtocolAuthBackend(verifiers=[oauth_verifier, api_key_verifier, mtls_verifier]) - - dpop_verifier: DPoPProofVerifier | None = None - if dpop_enabled: - dpop_verifier = DPoPProofVerifier(jti_store=InMemoryJTIReplayStore()) - - return backend, dpop_verifier - - -class MultiProtocolAuthBackendAdapter(AuthenticationBackend): - """Starlette AuthenticationBackend that wraps MultiProtocolAuthBackend.""" - - def __init__( - self, - backend: MultiProtocolAuthBackend, - dpop_verifier: DPoPProofVerifier | None = None, - ) -> None: - self._backend = backend - self._dpop_verifier = dpop_verifier - - async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthenticatedUser] | None: - request = cast(Request, conn) - - dpop_header = request.headers.get("dpop") - if self._dpop_verifier is not None: - if dpop_header: - logger.info("DPoP proof present, verification enabled") - else: - logger.debug("DPoP verification enabled but no DPoP header in request") - elif dpop_header: - logger.debug("DPoP header present but verification not enabled (ignoring)") - - result = await self._backend.verify(request, dpop_verifier=self._dpop_verifier) - - if result is None: - if dpop_header and self._dpop_verifier is not None: - logger.warning("Authentication failed (DPoP proof may be invalid)") - else: - logger.debug("Authentication failed (no valid credentials)") - return None - - if result.expires_at is not None and result.expires_at < int(time.time()): - logger.warning("Token expired for client_id=%s", result.client_id) - return None - - if dpop_header and self._dpop_verifier is not None: - logger.info("Authentication successful with DPoP (client_id=%s)", result.client_id) - else: - logger.info("Authentication successful (client_id=%s)", result.client_id) - - return ( - AuthCredentials(result.scopes or []), - AuthenticatedUser(result), - ) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py index a378eaa36..302d7cdd2 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/server.py @@ -1,256 +1,8 @@ -"""MCP Resource Server with multi-protocol auth (root-only unified discovery variant). +"""MCP Resource Server (multiprotocol, root-only unified discovery variant). -This variant: -- PRM does NOT include mcp_auth_protocols (only authorization_servers) -- Exposes only root unified discovery endpoint: /.well-known/authorization_servers +Thin shim — see mcp_simple_auth_multiprotocol.server for the canonical implementation. """ -import contextlib -import datetime -import logging -from typing import Any, Literal +from mcp_simple_auth_multiprotocol.server import VARIANT_ROOT_ONLY, main_for_variant -import click -import uvicorn -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Route -from starlette.types import ASGIApp - -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from mcp.server.auth.routes import ( - build_resource_metadata_url, - create_authorization_servers_discovery_routes, - create_protected_resource_routes, -) -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp.server import FastMCP, StreamableHTTPASGIApp -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.shared.auth import AuthProtocolMetadata - -from .multiprotocol import MultiProtocolAuthBackendAdapter, build_multiprotocol_backend -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the multi-protocol MCP Resource Server (root-only discovery).""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - host: str = "localhost" - port: int = 8002 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8002/mcp") - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - mcp_scope: str = "user" - oauth_strict: bool = False - api_key_valid_keys: str = "demo-api-key-12345" - default_protocol: str = "oauth2" - protocol_preferences: str = "oauth2:1,api_key:2,mutual_tls:3" - dpop_enabled: bool = False - - -def _protocol_metadata_list(settings: ResourceServerSettings) -> list[AuthProtocolMetadata]: - """Build AuthProtocolMetadata for oauth2, api_key, mutual_tls.""" - auth_base = str(settings.auth_server_url).rstrip("/") - oauth_metadata_url = AnyHttpUrl(f"{auth_base}/.well-known/oauth-authorization-server") - return [ - AuthProtocolMetadata( - protocol_id="oauth2", - protocol_version="2.0", - metadata_url=oauth_metadata_url, - scopes_supported=[settings.mcp_scope], - ), - AuthProtocolMetadata(protocol_id="api_key", protocol_version="1.0"), - AuthProtocolMetadata(protocol_id="mutual_tls", protocol_version="1.0"), - ] - - -def _protocol_preferences_dict(prefs_str: str) -> dict[str, int]: - """Parse protocol_preferences string like 'oauth2:1,api_key:2,mutual_tls:3'.""" - out: dict[str, int] = {} - for part in prefs_str.split(","): - s = part.strip() - if ":" in s: - proto, prio = s.split(":", 1) - try: - out[proto.strip()] = int(prio.strip()) - except ValueError: - pass - return out - - -def create_multiprotocol_resource_server(settings: ResourceServerSettings) -> Starlette: - """Create Starlette app with MultiProtocolAuthBackend, PRM (no mcp_auth_protocols) and root-only discovery.""" - oauth_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, - ) - api_key_keys = {k.strip() for k in settings.api_key_valid_keys.split(",") if k.strip()} - backend, dpop_verifier = build_multiprotocol_backend( - oauth_verifier, - api_key_keys, - api_key_scopes=[settings.mcp_scope], - dpop_enabled=settings.dpop_enabled, - ) - adapter = MultiProtocolAuthBackendAdapter(backend, dpop_verifier=dpop_verifier) - - fastmcp = FastMCP( - name="MCP Resource Server (multiprotocol, root-only discovery)", - instructions="Resource Server with OAuth, API Key, and Mutual TLS (placeholder) auth (root-only discovery)", - host=settings.host, - port=settings.port, - auth=None, - ) - - @fastmcp.tool() - async def get_time() -> dict[str, Any]: - """Return current server time (requires auth).""" - now = datetime.datetime.now() - return { - "current_time": now.isoformat(), - "timezone": "UTC", - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - mcp_server = getattr(fastmcp, "_mcp_server") - session_manager = StreamableHTTPSessionManager( - app=mcp_server, - event_store=None, - retry_interval=None, - json_response=False, - stateless=False, - security_settings=None, - ) - streamable_app: ASGIApp = StreamableHTTPASGIApp(session_manager) - - auth_settings = AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ) - resource_url = auth_settings.resource_server_url - assert resource_url is not None - resource_metadata_url = build_resource_metadata_url(resource_url) - protocols_metadata = _protocol_metadata_list(settings) - auth_protocol_ids = [p.protocol_id for p in protocols_metadata] - protocol_prefs = _protocol_preferences_dict(settings.protocol_preferences) - - require_auth = RequireAuthMiddleware( - streamable_app, - required_scopes=[settings.mcp_scope], - resource_metadata_url=resource_metadata_url, - auth_protocols=auth_protocol_ids, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - - routes: list[Route] = [ - Route( - "/mcp", - endpoint=require_auth, - ), - ] - - # PRM without MCP extensions: provide RFC 9728 authorization_servers/scopes only. - # This allows OAuth to locate the AS without letting clients discover protocols via PRM mcp_auth_protocols. - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[auth_settings.issuer_url], - scopes_supported=auth_settings.required_scopes, - # IMPORTANT: pass an explicit empty list to avoid ProtectedResourceMetadata backward-compat - # validator auto-filling mcp_auth_protocols from authorization_servers. - auth_protocols=[], - default_protocol=None, - protocol_preferences=None, - ) - ) - - # Root-based unified discovery endpoint only (/.well-known/authorization_servers) - routes.extend( - create_authorization_servers_discovery_routes( - protocols=protocols_metadata, - default_protocol=settings.default_protocol, - protocol_preferences=protocol_prefs if protocol_prefs else None, - ) - ) - # NOTE: root-only variant intentionally does NOT add path-relative unified discovery - # (no /.well-known/authorization_servers/mcp route) - - middleware = [ - Middleware(AuthenticationMiddleware, backend=adapter), - Middleware(AuthContextMiddleware), - ] - - @contextlib.asynccontextmanager - async def lifespan(app: Starlette): - async with session_manager.run(): - yield - - return Starlette( - debug=True, - routes=routes, - middleware=middleware, - lifespan=lifespan, - ) - - -@click.command() -@click.option("--port", default=8002, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol", -) -@click.option("--oauth-strict", is_flag=True, help="Enable RFC 8707 resource validation") -@click.option("--api-keys", default="demo-api-key-12345", help="Comma-separated valid API keys") -@click.option("--dpop-enabled", is_flag=True, help="Enable DPoP proof verification (RFC 9449)") -def main( - port: int, - auth_server: str, - transport: Literal["sse", "streamable-http"], - oauth_strict: bool, - api_keys: str, - dpop_enabled: bool, -) -> int: - """Run the multi-protocol MCP Resource Server (root-only discovery).""" - logging.basicConfig(level=logging.INFO) - try: - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=AnyHttpUrl(auth_server), - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - api_key_valid_keys=api_keys, - dpop_enabled=dpop_enabled, - ) - except ValueError as e: - logger.error("Configuration error: %s", e) - return 1 - - app = create_multiprotocol_resource_server(settings) - logger.info("Multi-protocol RS (root-only discovery) running on %s", settings.server_url) - logger.info("Auth: OAuth (introspection), API Key (X-API-Key or Bearer ), mTLS (placeholder)") - if dpop_enabled: - logger.info("DPoP: enabled (RFC 9449)") - uvicorn.run(app, host=settings.host, port=settings.port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +main = main_for_variant(VARIANT_ROOT_ONLY) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py deleted file mode 100644 index 52db9b783..000000000 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol_root_only/token_verifier.py +++ /dev/null @@ -1,76 +0,0 @@ -"""OAuth token verifier using introspection (root-only variant reuses same logic).""" - -import logging -from typing import Any, cast - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Verify Bearer tokens via OAuth 2.0 Token Introspection (RFC 7662).""" - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning("Rejecting unsafe introspection endpoint") - return None - - timeout = httpx.Timeout(10.0, connect=5.0) - async with httpx.AsyncClient(timeout=timeout, verify=True) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - return None - data = response.json() - if not data.get("active", False): - return None - if self.validate_resource and not self._validate_resource(data): - return None - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), - ) - except Exception as e: - logger.warning("Token introspection failed: %s", e) - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - if not self.server_url or not self.resource_url: - return False - aud = token_data.get("aud") - if isinstance(aud, list): - for item in cast(list[str], aud): - if self._is_valid_resource(item): - return True - return False - if isinstance(aud, str): - return self._is_valid_resource(aud) - return False - - def _is_valid_resource(self, resource: str) -> bool: - if not self.resource_url: - return False - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) From 278752a41ce10d7206958c61cafff61527d189b5 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 7 Feb 2026 18:55:54 +0800 Subject: [PATCH 62/64] fix(auth): correct path-relative discovery URL and enforce strict discovery order Fix path-relative URL to /.well-known/authorization_servers{path}. Refactor async_auth_flow to try path-relative then root discovery sequentially before falling back to PRM. --- src/mcp/client/auth/multi_protocol.py | 79 +++++++++++++++++++-------- src/mcp/client/auth/utils.py | 4 +- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/mcp/client/auth/multi_protocol.py b/src/mcp/client/auth/multi_protocol.py index f78a0e7cf..4527c6fc8 100644 --- a/src/mcp/client/auth/multi_protocol.py +++ b/src/mcp/client/auth/multi_protocol.py @@ -28,7 +28,6 @@ import time from collections.abc import AsyncGenerator from typing import Any, Protocol, cast -from urllib.parse import urljoin import anyio import httpx @@ -39,6 +38,7 @@ from mcp.client.auth.oauth2 import TokenStorage as OAuth2TokenStorage from mcp.client.auth.protocol import AuthContext, AuthProtocol, DPoPEnabledProtocol from mcp.client.auth.utils import ( + build_authorization_servers_discovery_urls, build_protected_resource_metadata_discovery_urls, create_oauth_metadata_request, extract_auth_protocols_from_www_auth, @@ -64,6 +64,33 @@ UNSPECIFIED_PROTOCOL_PREFERENCE: int = sys.maxsize +def _build_protocol_candidates( + *, + available: list[str], + default_protocol: str | None, + protocol_preferences: dict[str, int] | None, +) -> list[str]: + """Build an ordered, de-duplicated list of protocol IDs to attempt. + + Priority order: + 1) default_protocol (if provided) + 2) available protocols ordered by protocol_preferences (if provided) + 3) available protocols in original order + """ + candidates_raw: list[str | None] = [default_protocol] + if protocol_preferences is not None: + + def preference_key(protocol_id: str) -> int: + return protocol_preferences.get(protocol_id, UNSPECIFIED_PROTOCOL_PREFERENCE) + + candidates_raw.extend(sorted(available, key=preference_key)) + candidates_raw.extend(available) + + # De-duplicate while preserving order. + candidates_str = [pid for pid in candidates_raw if pid is not None] + return list(dict.fromkeys(candidates_str)) + + class TokenStorage(Protocol): """Credential storage interface (multi-protocol contract). @@ -252,6 +279,18 @@ async def _parse_protocols_from_discovery_response( self, response: httpx.Response, prm: ProtectedResourceMetadata | None ) -> list[AuthProtocolMetadata]: """Parse ``/.well-known/authorization_servers`` response; fall back to PRM if needed.""" + protocols = await self._parse_protocols_from_discovery_response_without_prm_fallback(response) + if protocols: + return protocols + if prm is not None and prm.mcp_auth_protocols: + return list(prm.mcp_auth_protocols) + return [] + + async def _parse_protocols_from_discovery_response_without_prm_fallback( + self, + response: httpx.Response, + ) -> list[AuthProtocolMetadata]: + """Parse ``/.well-known/authorization_servers`` response (no PRM fallback).""" if response.status_code == 200: try: content = await response.aread() @@ -262,8 +301,6 @@ async def _parse_protocols_from_discovery_response( return [AuthProtocolMetadata.model_validate(p) for p in protocols_data] except (ValidationError, ValueError, KeyError, TypeError) as e: logger.debug("Unified authorization_servers parse failed: %s", e) - if prm is not None and prm.mcp_auth_protocols: - return list(prm.mcp_auth_protocols) return [] async def _handle_403_response(self, response: httpx.Response, request: httpx.Request) -> None: @@ -313,13 +350,17 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. break # Step 2: Protocol discovery (yield) - discovery_url = urljoin( - server_url.rstrip("/") + "/", - ".well-known/authorization_servers", - ) - discovery_req = create_oauth_metadata_request(discovery_url) - discovery_resp = yield discovery_req - protocols_metadata = await self._parse_protocols_from_discovery_response(discovery_resp, prm) + protocols_metadata: list[AuthProtocolMetadata] = [] + for discovery_url in build_authorization_servers_discovery_urls(server_url): + discovery_req = create_oauth_metadata_request(discovery_url) + discovery_resp = yield discovery_req + protocols_metadata = await self._parse_protocols_from_discovery_response_without_prm_fallback( + discovery_resp + ) + if protocols_metadata: + break + if not protocols_metadata and prm is not None and prm.mcp_auth_protocols: + protocols_metadata = list(prm.mcp_auth_protocols) available: list[str] = ( [m.protocol_id for m in protocols_metadata] @@ -337,19 +378,11 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. else: # Select protocol candidates based on server hints, but only # attempt protocols that are actually injected as instances. - candidates_raw: list[str | None] = [default_protocol] - preferences = protocol_preferences - if preferences is not None: - - def preference_key(protocol_id: str) -> int: - return preferences.get(protocol_id, UNSPECIFIED_PROTOCOL_PREFERENCE) - - candidates_raw.extend(sorted(available, key=preference_key)) - candidates_raw.extend(available) - - # De-duplicate while preserving order. - candidates_str = [pid for pid in candidates_raw if pid is not None] - candidates = list(dict.fromkeys(candidates_str)) + candidates = _build_protocol_candidates( + available=available, + default_protocol=default_protocol, + protocol_preferences=protocol_preferences, + ) metadata_by_id = {m.protocol_id: m for m in protocols_metadata} if protocols_metadata else {} diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 3b73470e5..bef373e74 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -142,10 +142,10 @@ def build_authorization_servers_discovery_urls(resource_url: str) -> list[str]: urls: list[str] = [] - # Path-relative: https://host//.well-known/authorization_servers + # Path-relative: https://host/.well-known/authorization_servers if parsed.path and parsed.path != "/": path = parsed.path.rstrip("/") - urls.append(urljoin(base_url, f"{path}/.well-known/authorization_servers")) + urls.append(urljoin(base_url, f"/.well-known/authorization_servers{path}")) # Root: https://host/.well-known/authorization_servers urls.append(urljoin(base_url, "/.well-known/authorization_servers")) From 49da5e9c36517fa259b54d0dc18a66f50e735e65 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 7 Feb 2026 18:55:58 +0800 Subject: [PATCH 63/64] test(auth): update tests for strict discovery and path-relative URL fix --- .../test_multi_protocol_provider_coverage.py | 63 ++++++++++++++++--- ...t_utils_authorization_servers_discovery.py | 4 +- tests/client/test_multi_protocol_provider.py | 6 +- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/tests/client/auth/test_multi_protocol_provider_coverage.py b/tests/client/auth/test_multi_protocol_provider_coverage.py index 2ec3b64ed..46ad2dcaf 100644 --- a/tests/client/auth/test_multi_protocol_provider_coverage.py +++ b/tests/client/auth/test_multi_protocol_provider_coverage.py @@ -13,6 +13,7 @@ MultiProtocolAuthProvider, OAuthTokenStorageAdapter, TokenStorage, + _build_protocol_candidates, _credentials_to_storage, _oauth_token_to_credentials, ) @@ -82,6 +83,24 @@ def test_oauth_token_to_credentials_leaves_expires_at_none_when_expires_in_missi assert credentials.expires_at is None +def test_build_protocol_candidates_without_preferences_includes_default_then_available() -> None: + candidates = _build_protocol_candidates( + available=["api_key", "oauth2"], + default_protocol="oauth2", + protocol_preferences=None, + ) + assert candidates == ["oauth2", "api_key"] + + +def test_build_protocol_candidates_with_preferences_orders_and_deduplicates() -> None: + candidates = _build_protocol_candidates( + available=["api_key", "oauth2", "api_key"], + default_protocol=None, + protocol_preferences={"api_key": 1, "oauth2": 10}, + ) + assert candidates == ["api_key", "oauth2"] + + @pytest.mark.anyio async def test_helper_types_are_exercised_for_test_coverage() -> None: storage = _InMemoryDualStorage() @@ -143,6 +162,20 @@ async def test_parse_protocols_from_discovery_response_falls_back_to_prm_on_inva assert [p.protocol_id for p in protocols] == ["api_key"] +@pytest.mark.anyio +async def test_parse_protocols_from_discovery_response_returns_protocols_when_present() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + + response = httpx.Response( + 200, + json={"protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}]}, + request=httpx.Request("GET", "https://rs.example/.well-known/authorization_servers/mcp"), + ) + protocols = await provider._parse_protocols_from_discovery_response(response, prm=None) + assert [p.protocol_id for p in protocols] == ["api_key"] + + @pytest.mark.anyio async def test_parse_protocols_from_discovery_response_falls_back_to_prm_when_protocols_list_empty() -> None: storage = _InMemoryDualStorage() @@ -159,12 +192,25 @@ async def test_parse_protocols_from_discovery_response_falls_back_to_prm_when_pr response = httpx.Response( 200, json={"protocols": []}, - request=httpx.Request("GET", "https://rs/mcp/.well-known/authorization_servers"), + request=httpx.Request("GET", "https://rs/.well-known/authorization_servers/mcp"), ) protocols = await provider._parse_protocols_from_discovery_response(response, prm_validated) assert [p.protocol_id for p in protocols] == ["api_key"] +@pytest.mark.anyio +async def test_parse_protocols_from_discovery_response_returns_empty_when_no_protocols_and_no_prm() -> None: + storage = _InMemoryDualStorage() + provider = MultiProtocolAuthProvider(server_url="https://rs.example/mcp", storage=storage, protocols=[]) + + response = httpx.Response( + 404, + request=httpx.Request("GET", "https://rs.example/.well-known/authorization_servers/mcp"), + ) + protocols = await provider._parse_protocols_from_discovery_response(response, prm=None) + assert protocols == [] + + @pytest.mark.anyio async def test_handle_403_response_parses_fields() -> None: storage = _InMemoryDualStorage() @@ -386,7 +432,7 @@ def handler(request: httpx.Request) -> httpx.Response: json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, request=request, ) - if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response( 200, json={"protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}]}, @@ -434,7 +480,7 @@ def handler(request: httpx.Request) -> httpx.Response: json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, request=request, ) - if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response( 200, json={"protocols": [{"protocol_id": "api_key", "protocol_version": "1.0"}]}, @@ -494,7 +540,7 @@ def handler(request: httpx.Request) -> httpx.Response: json={"resource": "https://rs.example/mcp", "authorization_servers": ["https://as.example/"]}, request=request, ) - if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response(404, request=request) if request.method == "GET" and request.url.path == "/.well-known/oauth-authorization-server": return httpx.Response( @@ -567,7 +613,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"ok": True}, request=request) if request.method == "GET" and "oauth-protected-resource" in request.url.path: return httpx.Response(404, request=request) - if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response(200, json={"protocols": []}, request=request) return httpx.Response(404, request=request) @@ -599,9 +645,12 @@ def build_urls(www_auth_url: str | None, server_url: str) -> list[str]: discovery_request = await flow.asend( httpx.Response(401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, request=request) ) - assert discovery_request.url.path.endswith("/mcp/.well-known/authorization_servers") + assert discovery_request.url.path == "/.well-known/authorization_servers/mcp" + + root_discovery_request = await flow.asend(httpx.Response(200, json={"protocols": []}, request=discovery_request)) + assert root_discovery_request.url.path == "/.well-known/authorization_servers" - retry_request = await flow.asend(httpx.Response(200, json={"protocols": []}, request=discovery_request)) + retry_request = await flow.asend(httpx.Response(200, json={"protocols": []}, request=root_discovery_request)) assert retry_request is request with pytest.raises(StopAsyncIteration): await flow.asend(httpx.Response(200, json={"ok": True}, request=request)) diff --git a/tests/client/auth/test_utils_authorization_servers_discovery.py b/tests/client/auth/test_utils_authorization_servers_discovery.py index 32d50428e..94b2f4052 100644 --- a/tests/client/auth/test_utils_authorization_servers_discovery.py +++ b/tests/client/auth/test_utils_authorization_servers_discovery.py @@ -46,7 +46,7 @@ def test_build_authorization_servers_discovery_urls_deduplicates() -> None: @pytest.mark.anyio async def test_discover_authorization_servers_handles_parse_error_and_recovers() -> None: def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response(200, content=b"{not-json", request=request) if request.url.path == "/.well-known/authorization_servers": return httpx.Response( @@ -70,7 +70,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_discover_authorization_servers_returns_empty_when_no_protocols_and_no_prm() -> None: def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response(200, json={"protocols": []}, request=request) if request.url.path == "/.well-known/authorization_servers": return httpx.Response(200, json={"protocols": []}, request=request) diff --git a/tests/client/test_multi_protocol_provider.py b/tests/client/test_multi_protocol_provider.py index 886cece04..a7b15ca8e 100644 --- a/tests/client/test_multi_protocol_provider.py +++ b/tests/client/test_multi_protocol_provider.py @@ -265,7 +265,7 @@ def handler(request: httpx.Request) -> httpx.Response: } return httpx.Response(200, json=prm) - if request.method == "GET" and path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and path == "/.well-known/authorization_servers/mcp": return httpx.Response(404, text="not found") if request.method == "POST" and path == "/mcp": @@ -348,7 +348,7 @@ def handler(request: httpx.Request) -> httpx.Response: ], } return httpx.Response(200, json=prm) - if request.method == "GET" and request.url.path.endswith("/mcp/.well-known/authorization_servers"): + if request.method == "GET" and request.url.path == "/.well-known/authorization_servers/mcp": return httpx.Response(404, text="not found") if request.method == "POST" and request.url.path == "/mcp": www = ( @@ -387,7 +387,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert r.status_code == 401 # We should have attempted discovery, but final response must not be the discovery 404. - assert ("GET", "/mcp/.well-known/authorization_servers") in seen + assert ("GET", "/.well-known/authorization_servers/mcp") in seen assert handler(httpx.Request("GET", "https://rs.example/unexpected")).status_code == 500 From 27ce1a7cc4ad0043a5641944733bf2c08b808574 Mon Sep 17 00:00:00 2001 From: nypdmax Date: Sat, 7 Feb 2026 19:14:43 +0800 Subject: [PATCH 64/64] chore(examples): fix py.typed eof --- .../mcp_simple_auth_multiprotocol/py.typed | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed index 8b1378917..e69de29bb 100644 --- a/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed +++ b/examples/servers/simple-auth-multiprotocol/mcp_simple_auth_multiprotocol/py.typed @@ -1 +0,0 @@ -