MCP 协议设计与实现
第16章 服务发现与客户端注册
16.1 为什么服务发现是 MCP 的核心难题
上一章我们讲了 MCP 的 OAuth 2.1 授权框架。但那一章有一个关键前提被刻意搁置了:客户端怎么知道该去哪里做认证?
这个问题在传统 Web 应用中几乎不存在。你用 GitHub 登录某个网站,开发者在代码里硬编码了 https://github.com/login/oauth/authorize——因为开发者提前知道自己要对接 GitHub。但 MCP 的场景完全不同:
- 客户端和服务器之间没有预先关系。 Claude Desktop 不可能提前知道用户会连接哪个 MCP Server,更不可能提前知道那个 Server 用哪个授权服务器。
- 授权服务器可能和资源服务器分离。 一个企业内部的 MCP Server 可能用 Okta 做认证,另一个用 Auth0,还有一个用自建的 OAuth Server。
- 客户端需要自动完成整个流程。 不能让用户手动去查文档、填配置——那会彻底破坏 MCP"即插即用"的体验。
所以 MCP 面临的核心设计问题是:如何让一个完全陌生的客户端,在零配置的情况下,自动发现服务器的授权体系并完成注册?
MCP 协议给出的答案是一套层层递进的发现机制,涉及三个 RFC、一份 IETF 草案和一个 OIDC 规范。本章将从协议规范和 TypeScript SDK 源码两个维度,完整剖析这套机制的设计意图与实现细节。
16.2 发现流程的全景图
在深入每个环节之前,先看整体流程。MCP 的服务发现分为三个阶段:
flowchart TD
A["MCP 客户端发起请求"] --> B{"服务器返回 401?"}
B -- 是 --> C["阶段一:受保护资源元数据发现\nRFC 9728"]
B -- 否 --> Z["无需认证,直接通信"]
C --> D["从 WWW-Authenticate 或\nwell-known URI 获取资源元数据"]
D --> E["提取 authorization_servers 字段"]
E --> F["阶段二:授权服务器元数据发现\nRFC 8414 / OIDC Discovery"]
F --> G["按优先级探测 well-known 端点"]
G --> H["获取 token_endpoint、\nauthorization_endpoint 等"]
H --> I["阶段三:客户端注册"]
I --> J{"选择注册方式"}
J -- 预注册 --> K["使用已有 client_id"]
J -- "Client ID\n元数据文档" --> L["HTTPS URL 作为 client_id\ndraft-ietf-oauth-client-id-metadata-document"]
J -- 动态注册 --> M["RFC 7591\nPOST /register"]
K & L & M --> N["开始 OAuth 2.1 授权流程"]
这三个阶段环环相扣:第一阶段找到授权服务器在哪里,第二阶段获取授权服务器的能力和端点信息,第三阶段让客户端在授权服务器上建立身份。我们逐一深入。
16.3 阶段一:受保护资源元数据发现(RFC 9728)
16.3.1 为什么需要 RFC 9728
OAuth 2.0 的原始设计中,资源服务器(Resource Server)和授权服务器(Authorization Server)之间的关系是隐式的——客户端需要提前知道它们的对应关系。RFC 9728(OAuth 2.0 Protected Resource Metadata)填补了这个空白:它让资源服务器能够主动告诉客户端自己关联的授权服务器是谁。
MCP 协议规范(本专栏锚定的 2025-11-25 版,docs/specification/2025-11-25/basic/authorization.mdx)明确要求:
MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery.
这不是可选项,而是强制要求。
16.3.2 两种发现机制
MCP 服务器必须实现以下两种发现机制中的至少一种:
机制一:WWW-Authenticate 响应头
当客户端发送未认证请求时,服务器返回 401 Unauthorized,并在 WWW-Authenticate 头中包含资源元数据 URL:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
这个设计非常巧妙——它把发现信息嵌入了标准的 HTTP 认证挑战响应中,客户端不必再逐个探测 well-known URI。
机制二:Well-Known URI
如果 WWW-Authenticate 头中没有 resource_metadata 字段,客户端需要主动探测 well-known URI。规范规定了探测顺序(以 MCP 端点 https://example.com/public/mcp 为例):
- 先尝试带路径的 well-known URI:
https://example.com/.well-known/oauth-protected-resource/public/mcp - 如果失败,退回到根路径:
https://example.com/.well-known/oauth-protected-resource
为什么要有路径区分?因为同一个域名下可能运行多个 MCP Server,各自关联不同的授权服务器。路径级别的元数据允许细粒度的配置。
16.3.3 SDK 中的 WWW-Authenticate 解析
TypeScript SDK 在 packages/client/src/client/auth.ts 中实现了 WWW-Authenticate 头的解析。核心函数是 extractWWWAuthenticateParams:
// 文件:packages/client/src/client/auth.ts
export function extractWWWAuthenticateParams(
res: Response
): { resourceMetadataUrl?: URL; scope?: string; error?: string } {
const authenticateHeader = res.headers.get('WWW-Authenticate');
if (!authenticateHeader) {
return {};
}
const [type, scheme] = authenticateHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !scheme) {
return {};
}
const resourceMetadataMatch =
extractFieldFromWwwAuth(res, 'resource_metadata') || undefined;
let resourceMetadataUrl: URL | undefined;
if (resourceMetadataMatch) {
try {
resourceMetadataUrl = new URL(resourceMetadataMatch);
} catch {
// 忽略无效 URL
}
}
const scope = extractFieldFromWwwAuth(res, 'scope') || undefined;
const error = extractFieldFromWwwAuth(res, 'error') || undefined;
return { resourceMetadataUrl, scope, error };
}
这个函数从 401 响应中提取三个关键信息:
resourceMetadataUrl:资源元数据的 URL,指引客户端去哪里获取授权服务器信息scope:服务器要求的权限范围,用于后续授权请求error:错误类型,比如insufficient_scope(用于权限提升场景)
字段提取的底层实现使用正则表达式,同时支持带引号和不带引号两种格式:
function extractFieldFromWwwAuth(
response: Response, fieldName: string
): string | null {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
if (!wwwAuthHeader) return null;
const pattern = new RegExp(
String.raw`${fieldName}=(?:"([^"]+)"|([^\s,]+))`
);
const match = wwwAuthHeader.match(pattern);
if (match) {
const result = match[1] || match[2];
if (result) return result;
}
return null;
}
16.3.4 资源元数据的探测与回退
SDK 中的 discoverOAuthProtectedResourceMetadata 函数实现了完整的探测逻辑:
// 文件:packages/client/src/client/auth.ts(简化:省略了两处抛错前排空响应体的 response.text())
export async function discoverOAuthProtectedResourceMetadata(
serverUrl: string | URL,
opts?: {
protocolVersion?: string;
resourceMetadataUrl?: string | URL;
},
fetchFn: FetchLike = fetch
): Promise<OAuthProtectedResourceMetadata> {
const response = await discoverMetadataWithFallback(
serverUrl,
'oauth-protected-resource',
fetchFn,
{
protocolVersion: opts?.protocolVersion,
metadataUrl: opts?.resourceMetadataUrl
}
);
if (!response || response.status === 404) {
throw new Error(
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
);
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`
);
}
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
}
discoverMetadataWithFallback 是一个通用的元数据探测函数,它的回退策略值得关注:
async function discoverMetadataWithFallback(
serverUrl: string | URL,
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
fetchFn: FetchLike,
opts?: {
protocolVersion?: string;
metadataUrl?: string | URL;
metadataServerUrl?: string | URL;
}
): Promise<Response | undefined> {
const issuer = new URL(serverUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
let url: URL;
if (opts?.metadataUrl) {
// 如果指定了元数据 URL,直接使用
url = new URL(opts.metadataUrl);
} else {
// 先尝试路径感知的发现
const wellKnownPath = buildWellKnownPath(
wellKnownType, issuer.pathname
);
url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
url.search = issuer.search; // 保留 MCP 端点 URL 上的查询串
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
// 如果路径感知发现失败(4xx 或 502),且不在根路径,尝试根路径;
// 显式给了 metadataUrl(来自 WWW-Authenticate)时不回退
if (!opts?.metadataUrl
&& shouldAttemptFallback(response, issuer.pathname)) {
const rootUrl = new URL(
`/.well-known/${wellKnownType}`, issuer
);
response = await tryMetadataDiscovery(
rootUrl, protocolVersion, fetchFn
);
}
return response;
}
这里的 shouldAttemptFallback 函数决定了何时从路径级别回退到根级别:
function shouldAttemptFallback(
response: Response | undefined, pathname: string
): boolean {
if (!response) return true; // CORS 错误(只会在浏览器里出现),总是尝试回退
if (pathname === '/') return false; // 已经在根路径
return (response.status >= 400 && response.status < 500)
|| response.status === 502;
}
502 被特殊对待是一个有意思的细节——它通常意味着反向代理后面的服务不可用,此时尝试根路径可能会命中不同的后端服务。反过来,路径级 URL 返回 500 等其他 5xx 时 TS SDK 不会再试根路径:discoverOAuthProtectedResourceMetadata 直接抛错,随后被 §16.8 的 discoverOAuthServerInfo 当作「不支持 RFC 9728」吞掉,走旧版回退(本地假服务器实跑确认,见 §16.9.2)。
16.3.5 CORS 与浏览器环境的特殊处理
MCP 客户端可能运行在浏览器中(比如 Web 版的 AI 助手)。浏览器环境的 CORS 机制给元数据发现带来了额外的复杂性。SDK 用 fetchWithCorsRetry 函数处理这个问题:
// packages/client/src/client/auth.ts:1005(签名在源码里是一行长签名)
async function fetchWithCorsRetry(
url: URL,
headers?: Record<string, string>,
fetchFn: FetchLike = fetch
): Promise<Response | undefined> {
try {
return await fetchFn(url, { headers });
} catch (error) {
if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) {
throw error;
}
if (headers) {
// 可能是自定义头触发的 CORS 预检失败
// 去掉自定义头重试,变成"简单请求"
try {
return await fetchFn(url, {});
} catch (retryError) {
if (!(retryError instanceof TypeError)) {
throw retryError;
}
return undefined; // 服务器完全不支持 CORS
}
}
return undefined;
}
}
这段代码体现了一个重要的工程权衡:MCP 在请求中会添加 MCP-Protocol-Version 自定义头,这会触发浏览器的 CORS 预检请求(preflight)。如果服务器没有正确配置 CORS 来允许这个头,预检会失败。SDK 的策略是去掉自定义头重试——丢失版本信息总比完全无法发现好。
需要注意的是,CORS_IS_POSSIBLE 常量区分了浏览器和非浏览器环境。在 Node.js 中,fetch 抛出的 TypeError 意味着真正的网络错误(DNS 解析失败、连接被拒绝等),不应该被静默吞掉。
16.4 阶段二:授权服务器元数据发现
16.4.1 两个标准,多个端点
从资源元数据中拿到 authorization_servers 字段后,客户端知道了授权服务器的 URL(比如 https://auth.example.com)。但仅有 URL 还不够——客户端需要知道这个授权服务器支持哪些授权类型、token 端点在哪里、是否支持 PKCE 等。
MCP 规范要求授权服务器至少提供其中一种,客户端则两种都必须支持:
- RFC 8414(OAuth 2.0 Authorization Server Metadata):OAuth 原生的元数据发现
- OpenID Connect Discovery 1.0:OIDC 生态广泛使用的发现机制
为什么要支持两种?因为现实世界中两种都有大量部署。如果只支持 RFC 8414,就无法对接只实现了 OIDC Discovery 的 Identity Provider(如很多企业 IdP)。如果只支持 OIDC Discovery,又无法对接纯 OAuth 的授权服务器。MCP 选择了"两者都必须支持",把复杂性放在客户端,换取最大的兼容性。
16.4.2 Well-Known URI 的探测优先级
SDK 中的 buildDiscoveryUrls 函数构建了所有需要尝试的发现 URL,优先级清晰:
// 文件:packages/client/src/client/auth.ts
export function buildDiscoveryUrls(
authorizationServerUrl: string | URL
): { url: URL; type: 'oauth' | 'oidc' }[] {
const url = typeof authorizationServerUrl === 'string'
? new URL(authorizationServerUrl)
: authorizationServerUrl;
const hasPath = url.pathname !== '/';
const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = [];
if (!hasPath) {
// 无路径:https://auth.example.com
urlsToTry.push(
{
url: new URL(
'/.well-known/oauth-authorization-server',
url.origin
),
type: 'oauth'
},
{
url: new URL(
'/.well-known/openid-configuration',
url.origin
),
type: 'oidc'
}
);
return urlsToTry;
}
// 有路径:https://auth.example.com/tenant1
let pathname = url.pathname;
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
urlsToTry.push(
// 1. RFC 8414 路径插入式
{
url: new URL(
`/.well-known/oauth-authorization-server${pathname}`,
url.origin
),
type: 'oauth'
},
// 2. OIDC 路径插入式(RFC 8414 兼容写法)
{
url: new URL(
`/.well-known/openid-configuration${pathname}`,
url.origin
),
type: 'oidc'
},
// 3. OIDC Discovery 1.0 路径追加式
{
url: new URL(
`${pathname}/.well-known/openid-configuration`,
url.origin
),
type: 'oidc'
}
);
return urlsToTry;
}
这里有一个微妙的区别值得深入理解。对于带路径的授权服务器 URL https://auth.example.com/tenant1,RFC 8414 和 OIDC Discovery 1.0 对 well-known URI 的构造方式不同:
| 标准 | 构造方式 | 结果 URL |
|---|---|---|
| RFC 8414 | .well-known 前置于路径 |
/.well-known/oauth-authorization-server/tenant1 |
| OIDC(RFC 8414 兼容) | .well-known 前置于路径 |
/.well-known/openid-configuration/tenant1 |
| OIDC Discovery 1.0 原生 | .well-known 追加于路径 |
/tenant1/.well-known/openid-configuration |
第三种写法是 OIDC Discovery 1.0 规范原始定义的方式,在历史遗留的 OIDC Provider 中很常见。MCP 规范要求客户端按这个优先级依次尝试(前一个取不到再试下一个),才能覆盖各种部署场景;SDK 的顺序与 2025-11-25 规范列出的顺序完全一致。
16.4.3 发现与验证逻辑
discoverAuthorizationServerMetadata 函数按优先级逐个尝试 URL(抛错信息有简化):
export async function discoverAuthorizationServerMetadata(
authorizationServerUrl: string | URL,
{
fetchFn = fetch,
protocolVersion = LATEST_PROTOCOL_VERSION
}: { fetchFn?: FetchLike; protocolVersion?: string } = {}
): Promise<AuthorizationServerMetadata | undefined> {
const headers = {
'MCP-Protocol-Version': protocolVersion,
Accept: 'application/json'
};
const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
for (const { url: endpointUrl, type } of urlsToTry) {
const response = await fetchWithCorsRetry(
endpointUrl, headers, fetchFn
);
if (!response) continue; // CORS 错误,尝试下一个
if (!response.ok) {
await response.text?.().catch(() => {});
if ((response.status >= 400 && response.status < 500)
|| response.status === 502) {
continue; // 4xx 或 502,尝试下一个
}
throw new Error(
`HTTP ${response.status} trying to load metadata from ${endpointUrl}`
);
}
// 根据类型选择不同的 Schema 验证
return type === 'oauth'
? OAuthMetadataSchema.parse(await response.json())
: OpenIdProviderDiscoveryMetadataSchema.parse(
await response.json()
);
}
return undefined;
}
值得注意的是 type 字段的作用:OAuth 元数据和 OIDC 元数据的必填字段不同,需要用不同的 Schema 进行验证和解析。OAuthMetadataSchema 只要求 issuer、authorization_endpoint、token_endpoint、response_types_supported;OpenIdProviderDiscoveryMetadataSchema 还要求 jwks_uri、subject_types_supported、id_token_signing_alg_values_supported(packages/core/src/shared/auth.ts:50-126)。注意解析失败是直接抛 Zod 错误,不会继续尝试下一个 URL——只有 4xx、502 和浏览器里的 CORS 失败才会让循环往下走。
另外,2026-07-28 版规范新增一条:取回的元数据里 issuer 必须与构造 well-known URL 所用的 issuer 完全一致,否则不得使用;pin 版两个 SDK 的客户端发现代码都没有做这项比对。
16.4.4 元数据中的关键字段
无论是 OAuth 还是 OIDC 格式,授权服务器元数据中对 MCP 最重要的字段包括:
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
"client_id_metadata_document_supported": true,
"scopes_supported": ["read", "write"]
}
其中 code_challenge_methods_supported 对 MCP 至关重要——规范规定:如果这个字段缺失,说明授权服务器不支持 PKCE,MCP 客户端必须拒绝继续。这是 MCP 对安全性的强硬立场。但 pin 版 SDK 并没有完全做到:TS 的 startAuthorization(packages/client/src/client/auth.ts:1364,检查在其 1390-1395 行)只在该字段存在且不含 S256 时抛错,字段缺失时照样继续;Python 客户端则根本不读这个字段,固定发 code_challenge_method=S256(src/mcp/client/auth/oauth2.py:341)。
16.5 阶段三:客户端注册
16.5.1 三种注册方式的设计取舍
发现了授权服务器之后,客户端需要一个 client_id 来标识自己。MCP 支持三种注册方式,各有适用场景:
| 方式 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| 预注册 | 客户端和服务器有既定合作关系 | 最安全,完全可控 | 需要人工配置,不适合开放生态 |
| Client ID 元数据文档 | 客户端和服务器无预先关系 | 零交互注册,标准化 | 需要客户端托管 HTTPS 元数据 |
| 动态客户端注册 (RFC 7591) | 向后兼容,特殊需求 | 无需客户端托管元数据 | 安全风险更高 |
MCP 规范推荐(SHOULD)的优先级顺序是:
- 使用预注册信息(如果有)
- 使用 Client ID 元数据文档(如果授权服务器支持)
- 回退到动态客户端注册(如果授权服务器支持)
- 提示用户手动输入(最后手段)
SDK 中 authInternal 函数的注册逻辑体现了这个优先级:
// 文件:packages/client/src/client/auth.ts authInternal 函数片段(节选)
let clientInformation = await Promise.resolve(
provider.clientInformation()
);
if (!clientInformation) {
// 没有预注册信息
// ……(省略:带着 authorizationCode 却没有 client 信息时直接抛错)
const supportsUrlBasedClientId =
metadata?.client_id_metadata_document_supported === true;
const clientMetadataUrl = provider.clientMetadataUrl;
// ……(省略:clientMetadataUrl 不满足下文 isHttpsUrl 时抛 OAuthError)
const shouldUseUrlBasedClientId =
supportsUrlBasedClientId && clientMetadataUrl;
if (shouldUseUrlBasedClientId) {
// 使用 URL 作为 client_id
clientInformation = { client_id: clientMetadataUrl };
await provider.saveClientInformation?.(clientInformation);
} else {
// 回退到动态注册
if (!provider.saveClientInformation) {
throw new Error(
'OAuth client information must be saveable for dynamic registration'
);
}
const fullInformation = await registerClient(
authorizationServerUrl,
{
metadata,
clientMetadata: provider.clientMetadata,
scope: resolvedScope,
fetchFn
}
);
await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
}
}
16.5.2 Client ID 元数据文档:无预先关系时的推荐方式
Client ID 元数据文档(draft-ietf-oauth-client-id-metadata-document)是 MCP 规范推荐(SHOULD 支持)的注册方式,规范把它定位为「客户端与服务器没有预先关系(最常见)」时的选择,因为它解决了一个经典难题:如何在没有预先关系的情况下建立信任?
它的核心思想是:客户端用一个 HTTPS URL 作为自己的 client_id,这个 URL 指向一个 JSON 文档,描述了客户端的元信息。
sequenceDiagram
participant C as MCP 客户端
participant AS as 授权服务器
participant M as 客户端元数据端点<br/>(客户端自己托管)
Note over C: client_id = "https://app.example.com/oauth/metadata.json"
C->>AS: 授权请求<br/>client_id=https://app.example.com/oauth/metadata.json
Note over AS: 检测到 client_id 是 URL 格式
AS->>M: GET https://app.example.com/oauth/metadata.json
M-->>AS: 返回 JSON 元数据<br/>{client_id, client_name, redirect_uris, ...}
Note over AS: 验证:<br/>1. client_id 与 URL 完全匹配<br/>2. redirect_uri 在允许列表中<br/>3. JSON 结构合法
AS->>C: 继续授权流程
一个典型的元数据文档长这样:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
这个设计的安全基础在于 HTTPS 域名所有权即身份验证:只有控制 app.example.com 域名的人才能在那个 URL 上托管元数据文档。授权服务器可以据此决定是否信任这个客户端——它可以维护域名白名单,或者检查域名的信誉度。
SDK 在验证 clientMetadataUrl 时做了严格的校验:
export function isHttpsUrl(value?: string): boolean {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === 'https:' && url.pathname !== '/';
} catch {
return false;
}
}
注意 url.pathname !== '/' 这个条件——MCP 规范(沿用 CIMD 草案)要求 client_id URL 必须使用 https 并包含路径组件,不能仅仅是域名根路径。这避免了 https://example.com/ 这种过于宽泛的标识符。
16.5.3 动态客户端注册(RFC 7591)
当 Client ID 元数据文档不可用时(比如授权服务器不支持,或者客户端无法托管 HTTPS 元数据),MCP 回退到 RFC 7591 动态客户端注册。
SDK 中的 registerClient 函数实现了这个流程:
// 文件:packages/client/src/client/auth.ts
export async function registerClient(
authorizationServerUrl: string | URL,
{
metadata,
clientMetadata,
scope,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
scope?: string;
fetchFn?: FetchLike;
}
): Promise<OAuthClientInformationFull> {
let registrationUrl: URL;
if (metadata) {
if (!metadata.registration_endpoint) {
throw new Error(
'Incompatible auth server: does not support dynamic client registration'
);
}
registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL('/register', authorizationServerUrl);
}
const response = await (fetchFn ?? fetch)(registrationUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...clientMetadata,
...(scope === undefined ? {} : { scope })
})
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return OAuthClientInformationFullSchema.parse(await response.json());
}
动态注册的一个重要细节是 scope 的传递:如果调用者提供了 scope 参数,它会覆盖 clientMetadata.scope。这确保了注册时声明的权限范围与后续授权请求一致,遵循 MCP 的 Scope Selection Strategy。
2026-07-28 版规范已把动态客户端注册标为 Deprecated,推荐改用 Client ID 元数据文档,DCR 仅为兼容尚不支持 CIMD 的授权服务器而保留(docs/specification/2026-07-28/changelog.mdx:93)。
16.5.4 localhost 回调的安全隐患
Client ID 元数据文档方式有一个已知的安全隐患——localhost 重定向 URI 的欺骗问题。MCP 规范原文对此有明确的警告:
攻击者可以:
- 提供合法客户端的元数据 URL 作为自己的
client_id - 在本地绑定任意
localhost端口 - 将该端口地址作为
redirect_uri - 当用户批准授权时,攻击者就能通过重定向截获授权码
授权服务器看到的是合法客户端的元数据文档,用户看到的是合法客户端的名称——攻击很难被察觉。这就是为什么 MCP 规范要求授权服务器 MUST 在授权过程中清晰展示 redirect_uri 的主机名(并 SHOULD 对仅 localhost 的回调额外告警),让用户知道授权码将发送到哪里。
16.6 发现状态的缓存与持久化
16.6.1 为什么需要缓存
完整的发现流程涉及多个 HTTP 请求:探测受保护资源元数据、探测授权服务器元数据、可能还有客户端注册。每次建立连接都走一遍这个流程,既浪费时间又增加服务器负载。
SDK 通过 OAuthDiscoveryState 接口支持发现状态的缓存:
export interface OAuthDiscoveryState extends OAuthServerInfo {
resourceMetadataUrl?: string;
}
在 authInternal 函数中,SDK 首先检查是否有缓存的发现状态:
const cachedState = await provider.discoveryState?.();
if (cachedState?.authorizationServerUrl) {
// 从缓存恢复
authorizationServerUrl = cachedState.authorizationServerUrl;
resourceMetadata = cachedState.resourceMetadata;
metadata = cachedState.authorizationServerMetadata
?? (await discoverAuthorizationServerMetadata(
authorizationServerUrl, { fetchFn }
));
} else {
// 完整发现
const serverInfo = await discoverOAuthServerInfo(serverUrl, {
resourceMetadataUrl: effectiveResourceMetadataUrl,
fetchFn
});
// ... 保存到缓存
await provider.saveDiscoveryState?.({ ... });
}
16.6.2 缓存失效策略
缓存不是永久有效的——授权服务器可能迁移、配置可能变更。SDK 通过 invalidateCredentials 方法支持分层失效:
invalidateCredentials?(
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
): void | Promise<void>;
SDK 自己只在 auth() 捕获到特定 OAuth 错误时调用它,并且各只重试一次:invalid_client / unauthorized_client 调 invalidateCredentials('all') 后重跑,invalid_grant 调 invalidateCredentials('tokens') 后重跑(packages/client/src/client/auth.ts:549-566);SDK 从不主动传 'discovery'。清哪些东西由 provider 自己实现——接口注释建议 provider 在认证反复失败时通过 'discovery' 或 'all' 清掉发现缓存,以便授权服务器变更后能重新发现。2026-07-28 版规范进一步要求客户端按 issuer 分别保存凭据、授权服务器变更时必须重新注册。
16.7 Claude Desktop 如何发现 MCP Server
前面讨论的是 MCP 协议层面的服务发现。但还有一个更上层的问题:Claude Desktop 这样的宿主应用如何发现用户想要连接的 MCP Server?
这其实不是 MCP 协议本身的内容,而是宿主应用的实现决策。Claude Desktop 采用的是配置文件驱动的方式:用户在 claude_desktop_config.json 中声明 MCP Server:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx" }
}
}
}
(@modelcontextprotocol/server-github 在 npm 上已标记弃用,这里只作格式示意,见 §12.3 的说明。)
这里有两种接入方式:
- 本地 STDIO(配置文件里的
command+args):Claude Desktop 启动一个子进程,通过标准输入输出通信。这种模式下不需要 OAuth 发现——进程运行在本地,环境变量直接传递凭证。 - 远程 HTTP:官方文档给出的做法不是写配置文件,而是在 Claude 的 Connectors 设置里「Add custom connector」、填入远程 Server 的 URL(
docs/docs/2025-11-25/develop/connect-remote-servers.mdx;Claude 帮助中心 custom connectors 一文,核对日期 2026-09-24)。如果服务器需要认证,就会触发本章讨论的完整发现流程。
这种"用户声明 Server + 协议层自动发现授权"的双层架构是一个务实的设计:用户只需要知道 Server 的命令或 URL,认证细节由协议层自动处理。至于「去哪里找 Server」,生态里已经有了官方的 MCP Registry(https://registry.modelcontextprotocol.io):它只存 server.json 格式的元数据、通过 REST API 供客户端和聚合器发现 Server,仍处于 preview(2025-09-08 上线预览,2025-10-24 起 API 冻结在 v0.1),详见 §20.11.2(官方 registry 仓库 README 与规范仓库 docs/registry/about.mdx,核对日期 2026-09-24)。另有一项提案 MCP Server Card,打算让 Server 在 /.well-known/mcp.json 暴露元数据、不连接就能被发现;它目前只出现在规范仓库的路线图与 SEP 讨论里,2025-11-25 与 2026-07-28 两版规范正文和两个 pin 版 SDK 都没有它。2026-07-28 版新增的 server/discover RPC 则是连上之后查询版本与能力,与本章的授权发现不是一回事。
16.8 discoverOAuthServerInfo:完整发现流程的入口
SDK 提供了 discoverOAuthServerInfo 作为整个发现流程的统一入口,将受保护资源元数据发现和授权服务器元数据发现合并为一次调用:
export async function discoverOAuthServerInfo(
serverUrl: string | URL,
opts?: {
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
}
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;
try {
resourceMetadata =
await discoverOAuthProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: opts?.resourceMetadataUrl },
opts?.fetchFn
);
if (resourceMetadata.authorization_servers
&& resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl =
resourceMetadata.authorization_servers[0];
}
} catch (error) {
// TypeError 表示网络不可达,必须向上抛出
if (error instanceof TypeError) throw error;
// 其他错误:RFC 9728 不可用,回退
}
// 如果受保护资源元数据不可用,回退到
// 将 MCP Server URL 本身作为授权服务器
if (!authorizationServerUrl) {
authorizationServerUrl = String(new URL('/', serverUrl));
}
const authorizationServerMetadata =
await discoverAuthorizationServerMetadata(
authorizationServerUrl, { fetchFn: opts?.fetchFn }
);
return {
authorizationServerUrl,
authorizationServerMetadata,
resourceMetadata
};
}
这个函数的回退策略揭示了一个重要的兼容性决策:当 RFC 9728 的受保护资源元数据不可用时(比如服务器太旧,没有实现),SDK 不会直接报错,而是假设 MCP Server 所在域名的根路径就是授权服务器。这保证了对早期 MCP 实现的向后兼容——在第一版带授权的 MCP 规范(2025-03-26)中,MCP Server 自己充当授权服务器。注意这是 SDK 的兼容选择:2025-11-25 规范的时序图对「两个 well-known 都取不到」写的是「中止或使用预配置的值」。
但这里对 TypeError 的特殊处理值得注意:如果 fetch 抛出 TypeError,说明是 DNS 解析失败或连接被拒绝等网络层面的问题,这种情况不应该回退到其他 URL,必须向上抛出让调用者处理。
16.9 Scope 选择策略
发现流程中还有一个贯穿始终的问题:客户端应该请求哪些权限(scope)?
2025-11-25 规范的 Scope Selection Strategy 遵循最小权限原则,只有两步:
- 使用
WWW-Authenticate头中的scope参数 - 如果没有,使用受保护资源元数据中
scopes_supported的全部 scope;scopes_supported也没有定义时,省略scope参数
两个 SDK 都在第 2 步和「省略」之间各自插了一步兜底:TS 用客户端元数据里的 scope,Python 用授权服务器元数据的 scopes_supported(见 §16.9.2)。
TS SDK 中的 determineScope 函数实现了这个策略:
export function determineScope(options: {
requestedScope?: string;
resourceMetadata?: OAuthProtectedResourceMetadata;
authServerMetadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
}): string | undefined {
const {
requestedScope, resourceMetadata,
authServerMetadata, clientMetadata
} = options;
let effectiveScope =
requestedScope
|| resourceMetadata?.scopes_supported?.join(' ')
|| clientMetadata.scope;
// 如果授权服务器支持 offline_access
// 且客户端支持 refresh_token grant,自动追加
if (effectiveScope
&& authServerMetadata?.scopes_supported
?.includes('offline_access')
&& !effectiveScope.split(' ').includes('offline_access')
&& clientMetadata.grant_types?.includes('refresh_token')
) {
effectiveScope = `${effectiveScope} offline_access`;
}
return effectiveScope;
}
自动追加 offline_access 的逻辑很有意思:如果授权服务器支持离线访问,且客户端声明了 refresh_token 授权类型,SDK 会自动请求 offline_access scope。这是因为 OpenID Connect Core 用 offline_access scope 来请求 refresh token——不请求这个 scope,授权服务器可能不会返回 refresh token,导致客户端每次 token 过期都要重新走完整的授权流程。源码注释标的出处是 SEP-2207:这条在 2025-11-25 规范里还没有,2026-07-28 版才写入(客户端 MAY 在授权服务器元数据的 scopes_supported 含 offline_access 时追加它)。
16.9.1 两个 SDK 的服务发现实现规模对比
把本章涉及的发现函数按 pin 版源码逐个定位——
TS SDK——发现相关函数都在单个文件 packages/client/src/client/auth.ts(1745 行)里,WWW-Authenticate 解析在 871-956 行,发现主体集中在 965-1359 行(约 395 行):
| 函数 | 起始行 | 角色 |
|---|---|---|
extractWWWAuthenticateParams |
871 | 解析 401 的 WWW-Authenticate(§16.3.3) |
discoverOAuthProtectedResourceMetadata |
965 | RFC 9728 受保护资源元数据(§16.3.4) |
fetchWithCorsRetry |
1005 | 浏览器 CORS 失败时去掉自定义头重试(§16.3.5) |
buildWellKnownPath |
1033 | 拼 /.well-known/<类型><路径> |
discoverMetadataWithFallback |
1068 | 路径级 → 根路径回退(§16.3.4) |
discoverOAuthMetadata |
1106 | 只查 RFC 8414 的旧接口,已标 @deprecated,推荐改用 discoverAuthorizationServerMetadata |
buildDiscoveryUrls |
1152 | 授权服务器元数据候选 URL(§16.4.2) |
discoverAuthorizationServerMetadata |
1225 | 按 RFC 8414 → OIDC 顺序逐个尝试(§16.4.3) |
discoverOAuthServerInfo |
1317 | 完整发现入口(§16.8) |
Python SDK——辅助函数放在独立的 src/mcp/client/auth/utils.py(339 行,15 个函数),编排则内联在 src/mcp/client/auth/oauth2.py 的 async_auth_flow(506 行起,源码注释说明受生成器写法所限只能内联):
| 函数 | 起始行 | 角色 |
|---|---|---|
extract_field_from_www_auth / extract_scope_from_www_auth / extract_resource_metadata_from_www_auth |
19 / 40 / 49 | 三个独立的 WWW-Authenticate 字段提取函数 |
build_protected_resource_metadata_discovery_urls |
61 | 受保护资源元数据候选 URL |
get_client_metadata_scopes |
98 | scope 选择(§16.9) |
build_oauth_authorization_server_metadata_discovery_urls |
134 | 授权服务器元数据候选 URL(RFC 8414 + OIDC) |
handle_protected_resource_response / handle_auth_metadata_response / handle_registration_response / handle_token_response_scopes |
177 / 201 / 233 / 318 | 4 个响应处理函数 |
create_oauth_metadata_request / create_client_registration_request |
214 / 218 | 请求构造 |
is_valid_client_metadata_url / should_use_client_metadata_url / create_client_info_from_metadata_url |
249 / 269 / 295 | Client ID 元数据相关(§16.5.2) |
两条值得记住的工程对比——
- TS 把发现逻辑按调用层级自上而下排在
auth.ts的中后段,编排入口discoverOAuthServerInfo在最后;Python 把 URL 构造、响应处理拆成utils.py里的小函数(最长的也不到 45 行,命名按「动词_名词」:extract / build / handle / create / is / should),但把请求编排整段内联在async_auth_flow里——与 §3.8.3 末尾谈到的两边代码组织差异一脉相承。 - Python
utils.py339 行 +oauth2.py646 行 = 985 行;TSauth.ts1745 行,同口径下 TS 约为 Python 的 1.8 倍(1.77)。§15.9.4 的「2.7 倍」只拿oauth2.py646 行作分母;差距主要不在发现逻辑本身,而在auth.ts还导出一整套可单独调用的底层函数(含已标@deprecated的旧接口)并配了大量 JSDoc——§15.9.4 粗略统计注释约 600 行、空行 179 行。
buildWellKnownPath 虽然带一个 prependPathname 选项(可把 well-known 追加在路径之后),但 pin 版里它唯一的调用点(discoverMetadataWithFallback,packages/client/src/client/auth.ts:1082)没有传这个选项;§16.4.2 表中 OIDC「路径追加式」的 URL 是 buildDiscoveryUrls 用模板字符串直接拼出来的。
16.9.2 发现流程的失败语义:何时回退,何时暴露错误
服务发现最危险的地方不是 URL 拼错,而是把“服务器不支持发现”和“网络不可达”混成一种失败。TypeScript SDK 的 discoverOAuthServerInfo() 对这个边界做了明确处理:packages/client/src/client/auth.ts:1317-1335 先尝试 RFC 9728 Protected Resource Metadata,并从 authorization_servers 中选择授权服务器;packages/client/src/client/auth.ts:1336-1344 只在非网络类失败时回退,遇到 TypeError 这类 DNS、连接拒绝等可达性问题会继续抛出;packages/client/src/client/auth.ts:1346-1352 才把没有发现到授权服务器的情况回退为“把 server base URL 当成授权服务器”。这条分界线很重要:网络坏了不能悄悄换一个授权服务器,否则安全事件会被伪装成兼容逻辑。
Python SDK 也把发现 URL 的优先级写成了可读的函数。src/mcp/client/auth/utils.py:49-58 只会从 401 响应的 WWW-Authenticate 中提取 resource_metadata;src/mcp/client/auth/utils.py:61-95 再按三个候选构造发现 URL:header 指定的 URL、带资源路径的 well-known URL、根 well-known URL。这个顺序对应生产里的三种部署:网关可以显式告诉客户端 metadata 在哪里;多租户服务可以把 /mcp/team-a 映射到路径级 metadata;最简单的单租户服务可以只暴露根级 metadata。
两个 SDK 的发现顺序大体一致,但边界行为不同。用一个本地假服务器(MCP 端点 /public/mcp,授权服务器 /tenant1,只在 /tenant1/.well-known/openid-configuration 提供元数据)分别跑 pin 版 TS 与 Python 客户端,记录下的每一次请求如下(实跑,2026-09-25):
# 场景 A:401 不带 resource_metadata,只有根路径 PRM —— TS 与 Python 相同
GET /.well-known/oauth-protected-resource/public/mcp 404
GET /.well-known/oauth-protected-resource 200 authorization_servers=[…/tenant1]
GET /.well-known/oauth-authorization-server/tenant1 404
GET /.well-known/openid-configuration/tenant1 404
GET /tenant1/.well-known/openid-configuration 200
# 场景 B:401 带 resource_metadata=".../custom/prm.json" —— 两边都只取这一个,不再探测 well-known
GET /custom/prm.json 200
(随后三条授权服务器元数据请求同场景 A)
# 场景 C:完全没有 PRM(旧服务器)
TS: GET PRM 路径级 404 → GET PRM 根路径 404
→ GET /.well-known/oauth-authorization-server → GET /.well-known/openid-configuration
Python: GET PRM 路径级 404 → GET PRM 根路径 404
→ GET /.well-known/oauth-authorization-server(只此一个,不试 OIDC)
# 场景 D:PRM 路径级返回 500
TS: GET PRM 路径级 500 → 不试根路径,直接按场景 C 的旧版回退去查根域名
Python: GET PRM 路径级 500 → GET PRM 根路径 → …(任何非 200 都换下一个 URL)
所有发现请求都带 MCP-Protocol-Version: 2025-11-25 头。差异来自源码:TS 只在 4xx/502 时回退根路径(shouldAttemptFallback),而且 resource_metadata 指定的 URL 失败后不再回退;Python 的 PRM 候选列表把 header URL、路径级、根路径三个都排进去,逐个尝试直到拿到 200(src/mcp/client/auth/utils.py:61-95、src/mcp/client/auth/oauth2.py:540-559)。没有 PRM 时,Python 按 2025-03-26 旧规范只查 oauth-authorization-server(src/mcp/client/auth/utils.py:142-146),TS 则照样把 OIDC 也试一遍。
scope 选择也属于发现的一部分,而不是授权按钮之后的临时决定。src/mcp/client/auth/utils.py:98-132 规定优先使用 WWW-Authenticate 里的 scope,其次用 Protected Resource Metadata 的 scopes_supported,再其次用 Authorization Server Metadata 的 scopes_supported,最后才省略 scope;如果授权服务器支持 offline_access 且客户端 grant_types 能用 refresh token,还会追加 refresh token 所需 scope。这里的工程意义是:scope 应尽量由资源服务器的挑战或资源元数据驱动,因为资源服务器最清楚访问当前资源需要什么权限。
服务端侧的发现 URL 也不是随手拼接。src/mcp/server/auth/routes.py:185-200 的 build_resource_metadata_url() 会把 /.well-known/oauth-protected-resource 插到 host 与 resource path 之间;src/mcp/server/auth/routes.py:203-245 用这个 URL 注册 Protected Resource Metadata 路由,并返回 resource、authorization_servers、scopes_supported 等字段。这个设计使 /mcp、/tenant/a/mcp 这类路径部署都能有对应的 metadata endpoint,不必把所有资源都挤到根路径。
因此,发现流程的实战排错可以按四问展开:第一,401 是否带了 WWW-Authenticate,其中有没有 resource_metadata 和 scope;第二,路径级 well-known 与根 well-known 是否都能访问;第三,PRM 里的 authorization_servers 是否指向真实授权服务器,而不是 resource server 自己;第四,授权服务器 metadata 是否声明了客户端要用的 grant type、PKCE 方法和注册方式。四问能定位大多数“OAuth 配好了但客户端还是不能连”的问题。
还要把缓存当成发现流程的一部分。发现结果可以缓存,因为 metadata endpoint 不应该每次工具调用都打;但缓存键必须至少区分 server URL、resource metadata URL、authorization server URL 和 scope 来源。否则多租户路径 /mcp/a 与 /mcp/b 可能复用同一份 authorization server metadata,短期看是少了一次请求,长期看就是把错误 scope 发给错误租户。缓存失效也不宜只靠固定时间,401/403 挑战里的新 scope 或新的 resource_metadata URL 都应触发重发现。
对浏览器型客户端,还要额外验证 CORS。metadata endpoint、token endpoint 和动态注册 endpoint 的 CORS 策略不同,授权端点通常应通过重定向访问而不是 AJAX 读取。发现逻辑如果只在 Node CLI 中测试过,搬到 WebView 或浏览器扩展里就可能卡在预检请求上。
所以发现模块的测试矩阵至少要覆盖 CLI、浏览器/WebView、带路径的 resource server、显式 resource_metadata header 和无 PRM 的旧服务。少一类环境,都可能把兼容问题推迟到用户现场。
16.10 设计反思与工程启示
MCP 的服务发现机制涉及四个 RFC/草案标准(RFC 9728、RFC 8414、RFC 7591、draft-ietf-oauth-client-id-metadata-document),加上 OpenID Connect Discovery 1.0。这种复杂度不是偶然的,而是 MCP 试图解决的问题本身就很复杂:在一个完全去中心化的生态中,让陌生的客户端和服务器自动完成认证握手。
从这套机制中可以提炼出几个值得借鉴的协议设计原则:
渐进式退化。 每一步都有回退策略。WWW-Authenticate 头没有 resource_metadata?回退到 well-known URI。RFC 8414 发现失败?尝试 OIDC Discovery。Client ID 元数据文档不可用?回退到动态注册。这种层层回退保证了在各种部署环境下都能工作。
把复杂性放在客户端。 MCP 的服务器端实现相对简单——提供一个 well-known 端点返回 JSON 即可。所有的探测、回退、兼容性处理都在客户端。这是一个明智的决策:客户端(宿主应用)的数量远少于 MCP Server 的数量。让少数客户端承担复杂性,比让每个服务器都实现复杂逻辑要经济得多。
安全性不可妥协。 即使在向后兼容和易用性的压力下,MCP 规范对 PKCE 的要求是刚性的——code_challenge_methods_supported 缺失则拒绝继续(pin 版 SDK 尚未完全落实,见 §16.4.4)。对 HTTPS 的要求也是刚性的——client_id URL 必须是 HTTPS,没有例外。在安全和便利之间,MCP 选择了安全。