MCP协议设计与实现-第16章 服务发现与客户端注册

《MCP 协议设计与实现》完整目录

第16章 服务发现与客户端注册

16.1 为什么服务发现是 MCP 的核心难题

上一章我们讲了 MCP 的 OAuth 2.1 授权框架。但那一章有一个关键前提被刻意搁置了:客户端怎么知道该去哪里做认证?

这个问题在传统 Web 应用中几乎不存在。你用 GitHub 登录某个网站,开发者在代码里硬编码了 https://github.com/login/oauth/authorize------因为开发者提前知道自己要对接 GitHub。但 MCP 的场景完全不同:

  1. 客户端和服务器之间没有预先关系。 Claude Desktop 不可能提前知道用户会连接哪个 MCP Server,更不可能提前知道那个 Server 用哪个授权服务器。
  2. 授权服务器可能和资源服务器分离。 一个企业内部的 MCP Server 可能用 Okta 做认证,另一个用 Auth0,还有一个用自建的 OAuth Server。
  3. 客户端需要自动完成整个流程。 不能让用户手动去查文档、填配置------那会彻底破坏 MCP"即插即用"的体验。

所以 MCP 面临的核心设计问题是:如何让一个完全陌生的客户端,在零配置的情况下,自动发现服务器的授权体系并完成注册?

MCP 协议给出的答案是一套层层递进的发现机制,涉及三个 RFC 标准和一个 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 协议规范明确要求:

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。探测顺序有讲究:

  1. 先尝试带路径的 well-known URI:https://example.com/.well-known/oauth-protected-resource/public/mcp
  2. 如果失败,退回到根路径: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 协议要求客户端支持两种发现标准:

  1. RFC 8414(OAuth 2.0 Authorization Server Metadata):OAuth 原生的元数据发现
  2. 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 规范推荐的优先级顺序是:

  1. 使用预注册信息(如果有)
  2. 使用 Client ID 元数据文档(如果授权服务器支持)
  3. 回退到动态客户端注册(如果授权服务器支持)
  4. 提示用户手动输入(最后手段)

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 文档,描述了客户端的元信息。

sequenceDiagram participant C as MCP 客户端 participant AS as 授权服务器 participant M as 客户端元数据端点
(客户端自己托管) 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 规范原文对此有明确的警告:

攻击者可以:

  1. 提供合法客户端的元数据 URL 作为自己的 client_id
  2. 在本地绑定任意 localhost 端口
  3. 将该端口地址作为 redirect_uri
  4. 当用户批准授权时,攻击者就能通过重定向截获授权码

授权服务器看到的是合法客户端的元数据文档,用户看到的是合法客户端的名称------攻击完全透明。这就是为什么 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" }
    }
  }
}

这里有两种模式:

  1. STDIO 模式command + args):Claude Desktop 启动一个子进程,通过标准输入输出通信。这种模式下不需要 OAuth 发现------进程运行在本地,环境变量直接传递凭证。
  2. 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 遵循最小权限原则,优先级如下:

  1. 使用 WWW-Authenticate 头中的 scope 参数
  2. 如果没有,使用受保护资源元数据中的 scopes_supported
  3. 如果还没有,使用客户端元数据中的 scope
  4. 如果都没有,省略 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 和更多样化的部署环境出现时,这套机制能够自适应地工作,而不需要人工干预------这正是一个成功协议应有的特质。

相关推荐
杨艺韬3 小时前
MCP协议设计与实现-第04章 生命周期与能力协商
agent
杨艺韬3 小时前
MCP协议设计与实现-第14章 SSE 与 WebSocket
agent
杨艺韬3 小时前
MCP协议设计与实现-第6章 Resource:结构化的上下文注入
agent
杨艺韬3 小时前
MCP协议设计与实现-第10章 Python Server 实现剖析
agent
杨艺韬3 小时前
MCP协议设计与实现-第12章 STDIO 传输:本地进程通信
agent
杨艺韬3 小时前
MCP协议设计与实现-第02章 架构总览:Host-Client-Server 模型
agent
杨艺韬3 小时前
MCP协议设计与实现-第1章 为什么需要 MCP
agent
杨艺韬3 小时前
MCP协议设计与实现-第13章 Streamable HTTP:远程流式传输
agent
杨艺韬3 小时前
MCP协议设计与实现-第11章 Python Client 实现剖析
agent