《MCP 协议设计与实现》完整目录
- 前言
- 第1章 为什么需要 MCP
- 第02章 架构总览:Host-Client-Server 模型
- 第03章 JSON-RPC 与消息格式
- 第04章 生命周期与能力协商
- 第05章 Tool:让 Agent 调用世界
- 第6章 Resource:结构化的上下文注入
- 第7章 Prompt:可复用的交互模板
- 第8章 TypeScript Server 实现剖析
- 第09章 TypeScript Client 实现剖析
- 第10章 Python Server 实现剖析
- 第11章 Python Client 实现剖析
- 第12章 STDIO 传输:本地进程通信
- 第13章 Streamable HTTP:远程流式传输
- 第14章 SSE 与 WebSocket
- 第15章 OAuth 2.1 认证框架
- 第16章 服务发现与客户端注册(当前)
- 第17章 sampling
- 第18章 Elicitation、Roots 与配置管理
- 第19章 Claude Code 的 MCP 客户端:12 万行的实战
- 第20章 从零构建一个生产级 MCP Server
- 第21章 设计模式与架构决策
第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 标准和一个 OIDC 规范。本章将从协议规范和 TypeScript SDK 源码两个维度,完整剖析这套机制的设计意图与实现细节。
16.2 发现流程的全景图
在深入每个环节之前,先看整体流程。MCP 的服务发现分为三个阶段:
这三个阶段环环相扣:第一阶段找到授权服务器在哪里,第二阶段获取授权服务器的能力和端点信息,第三阶段让客户端在授权服务器上建立身份。我们逐一深入。
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 协议规范明确要求:
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
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
如果 WWW-Authenticate 头中没有 resource_metadata 字段,客户端需要主动探测 well-known URI。探测顺序有讲究:
- 先尝试带路径的 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:
typescript
// 文件: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(用于权限提升场景)
字段提取的底层实现使用正则表达式,同时支持带引号和不带引号两种格式:
typescript
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 函数实现了完整的探测逻辑:
typescript
// 文件:packages/client/src/client/auth.ts
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 是一个通用的元数据探测函数,它的回退策略值得关注:
typescript
async function discoverMetadataWithFallback(
serverUrl: string | URL,
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
fetchFn: FetchLike,
opts?: { protocolVersion?: string; metadataUrl?: string | URL }
): Promise<Response | undefined> {
const issuer = new URL(serverUrl);
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);
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
// 如果路径感知发现失败(4xx 或 502),且不在根路径,尝试根路径
if (!opts?.metadataUrl
&& shouldAttemptFallback(response, issuer.pathname)) {
const rootUrl = new URL(
`/.well-known/${wellKnownType}`, issuer
);
response = await tryMetadataDiscovery(
rootUrl, protocolVersion, fetchFn
);
}
return response;
}
这里的 shouldAttemptFallback 函数决定了何时从路径级别回退到根级别:
typescript
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 被特殊对待是一个有意思的细节------它通常意味着反向代理后面的服务不可用,此时尝试根路径可能会命中不同的后端服务。
16.3.5 CORS 与浏览器环境的特殊处理
MCP 客户端可能运行在浏览器中(比如 Web 版的 AI 助手)。浏览器环境的 CORS 机制给元数据发现带来了额外的复杂性。SDK 用 fetchWithCorsRetry 函数处理这个问题:
typescript
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,优先级清晰:
typescript
// 文件: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 客户端必须三种都尝试,才能覆盖所有可能的部署场景。
16.4.3 发现与验证逻辑
discoverAuthorizationServerMetadata 函数按优先级逐个尝试 URL:
typescript
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 元数据的 JSON 结构不完全相同,需要用不同的 Schema 进行验证和解析。比如 OIDC 的 userinfo_endpoint 在纯 OAuth 中是不存在的,而 OAuth 的某些字段名可能与 OIDC 略有不同。SDK 在底层的 Schema 定义中处理了这些差异,对上层调用者来说是透明的。
16.4.4 元数据中的关键字段
无论是 OAuth 还是 OIDC 格式,授权服务器元数据中对 MCP 最重要的字段包括:
json
{
"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 对安全性的强硬立场。
16.5 阶段三:客户端注册
16.5.1 三种注册方式的设计取舍
发现了授权服务器之后,客户端需要一个 client_id 来标识自己。MCP 支持三种注册方式,各有适用场景:
| 方式 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| 预注册 | 客户端和服务器有既定合作关系 | 最安全,完全可控 | 需要人工配置,不适合开放生态 |
| Client ID 元数据文档 | 客户端和服务器无预先关系 | 零交互注册,标准化 | 需要客户端托管 HTTPS 元数据 |
| 动态客户端注册 (RFC 7591) | 向后兼容,特殊需求 | 无需客户端托管元数据 | 安全风险更高 |
MCP 规范推荐的优先级顺序是:
- 使用预注册信息(如果有)
- 使用 Client ID 元数据文档(如果授权服务器支持)
- 回退到动态客户端注册(如果授权服务器支持)
- 提示用户手动输入(最后手段)
SDK 中 authInternal 函数的注册逻辑体现了这个优先级:
typescript
// 文件:packages/client/src/client/auth.ts authInternal 函数片段
let clientInformation = await Promise.resolve(
provider.clientInformation()
);
if (!clientInformation) {
// 没有预注册信息
const supportsUrlBasedClientId =
metadata?.client_id_metadata_document_supported === true;
const clientMetadataUrl = provider.clientMetadataUrl;
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 最推荐的注册方式,因为它解决了一个经典难题:如何在没有预先关系的情况下建立信任?
它的核心思想是:客户端用一个 HTTPS URL 作为自己的 client_id,这个 URL 指向一个 JSON 文档,描述了客户端的元信息。
(客户端自己托管) Note over C: client_id = "https://app.example.com/oauth/metadata.json" C->>AS: 授权请求
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 元数据
{client_id, client_name, redirect_uris, ...} Note over AS: 验证:
1. client_id 与 URL 完全匹配
2. redirect_uri 在允许列表中
3. JSON 结构合法 AS->>C: 继续授权流程
一个典型的元数据文档长这样:
json
{
"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 时做了严格的校验:
typescript
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 !== '/' 这个条件------RFC 要求 client_id URL 必须包含路径组件,不能仅仅是域名根路径。这避免了 https://example.com/ 这种过于宽泛的标识符。
16.5.3 动态客户端注册(RFC 7591)
当 Client ID 元数据文档不可用时(比如授权服务器不支持,或者客户端无法托管 HTTPS 元数据),MCP 回退到 RFC 7591 动态客户端注册。
SDK 中的 registerClient 函数实现了这个流程:
typescript
// 文件: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。
16.5.4 localhost 回调的安全隐患
Client ID 元数据文档方式有一个已知的安全隐患------localhost 重定向 URI 的欺骗问题。MCP 规范原文对此有明确的警告:
攻击者可以:
- 提供合法客户端的元数据 URL 作为自己的
client_id - 在本地绑定任意
localhost端口 - 将该端口地址作为
redirect_uri - 当用户批准授权时,攻击者就能通过重定向截获授权码
授权服务器看到的是合法客户端的元数据文档,用户看到的是合法客户端的名称------攻击完全透明。这就是为什么 MCP 规范建议授权服务器在授权页面上醒目显示回调地址的主机名,让用户知道授权码将发送到哪里。
16.6 发现状态的缓存与持久化
16.6.1 为什么需要缓存
完整的发现流程涉及多个 HTTP 请求:探测受保护资源元数据、探测授权服务器元数据、可能还有客户端注册。每次建立连接都走一遍这个流程,既浪费时间又增加服务器负载。
SDK 通过 OAuthDiscoveryState 接口支持发现状态的缓存:
typescript
export interface OAuthDiscoveryState extends OAuthServerInfo {
resourceMetadataUrl?: string;
}
在 authInternal 函数中,SDK 首先检查是否有缓存的发现状态:
typescript
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 方法支持分层失效:
typescript
invalidateCredentials?(
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
): void | Promise<void>;
当认证反复失败时,SDK 会先尝试清除 token('tokens'),如果还是失败则清除全部状态('all'),包括发现缓存。这种渐进式失效策略避免了不必要的重新发现,同时确保了在配置变更时能够自动恢复。
16.7 Claude Desktop 如何发现 MCP Server
前面讨论的是 MCP 协议层面的服务发现。但还有一个更上层的问题:Claude Desktop 这样的宿主应用如何发现用户想要连接的 MCP Server?
这其实不是 MCP 协议本身的内容,而是宿主应用的实现决策。Claude Desktop 采用的是配置文件驱动 的方式:用户在 claude_desktop_config.json 中声明 MCP Server:
json
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "ghp_xxx" }
},
"custom-api": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
}
}
}
这里有两种模式:
- STDIO 模式 (
command+args):Claude Desktop 启动一个子进程,通过标准输入输出通信。这种模式下不需要 OAuth 发现------进程运行在本地,环境变量直接传递凭证。 - HTTP 模式 (
url):Claude Desktop 通过 HTTP 连接远程 MCP Server。如果服务器需要认证,就会触发本章讨论的完整发现流程。
这种"配置文件声明 + 协议层自动发现"的双层架构是一个务实的设计:用户只需要知道 Server 的名字或 URL,认证细节由协议层自动处理。未来 MCP 生态可能会出现 Server Registry 或 DNS-SD 等更自动化的发现机制,但当前阶段,配置文件是最可靠的选择。
16.8 discoverOAuthServerInfo:完整发现流程的入口
SDK 提供了 discoverOAuthServerInfo 作为整个发现流程的统一入口,将受保护资源元数据发现和授权服务器元数据发现合并为一次调用:
typescript
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 规范中,资源服务器和授权服务器被假设是同一个实体。
但这里对 TypeError 的特殊处理值得注意:如果 fetch 抛出 TypeError,说明是 DNS 解析失败或连接被拒绝等网络层面的问题,这种情况不应该回退到其他 URL,必须向上抛出让调用者处理。
16.9 Scope 选择策略
发现流程中还有一个贯穿始终的问题:客户端应该请求哪些权限(scope)?
MCP 的 Scope Selection Strategy 遵循最小权限原则,优先级如下:
- 使用
WWW-Authenticate头中的scope参数 - 如果没有,使用受保护资源元数据中的
scopes_supported - 如果还没有,使用客户端元数据中的
scope - 如果都没有,省略
scope参数
SDK 中的 determineScope 函数实现了这个策略:
typescript
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。这是因为 OIDC 规范要求通过 offline_access scope 来获取 refresh token------不请求这个 scope,授权服务器可能不会返回 refresh token,导致客户端每次 token 过期都要重新走完整的授权流程。
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 即可。所有的探测、回退、兼容性处理都在客户端。这是一个明智的决策:客户端数量少(几十个 Agent 平台),服务端数量多(成千上万的 MCP Server)。让少数客户端承担复杂性,比让每个服务器都实现复杂逻辑要经济得多。
安全性不可妥协。 即使在向后兼容和易用性的压力下,MCP 对 PKCE 的要求是刚性的------code_challenge_methods_supported 缺失则拒绝继续。对 HTTPS 的要求也是刚性的------client_id URL 必须是 HTTPS,没有例外。在安全和便利之间,MCP 选择了安全。
这套发现与注册机制,虽然在初始实现时增加了开发成本,但为 MCP 生态的可扩展性打下了坚实的基础。当未来有更多的 MCP Server 和更多样化的部署环境出现时,这套机制能够自适应地工作,而不需要人工干预------这正是一个成功协议应有的特质。