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 客户端元数据端点<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: 继续授权流程

一个典型的元数据文档长这样:

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 和更多样化的部署环境出现时,这套机制能够自适应地工作,而不需要人工干预------这正是一个成功协议应有的特质。

相关推荐
山间小僧2 小时前
「AI学习笔记」Agent Memory(二)跨会话记忆:从聊天记录到可演化的长期记忆
aigc·agent·vibecoding
dong_junshuai9 小时前
每天一个开源项目#99 OpenResearch:2.2K星的本地研究Agent工作台
开源·github·agent
花椒技术9 小时前
把 AI 视频接进直播:提前生成、按次生成、持续生成怎么选?
agent·音视频开发
用户8082598666879 小时前
用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管
agent
能不能静下心来看9 小时前
从 RAG 到 Agent:跑通两个 Demo 后,我终于分清了这两个词
agent
ZGi.ai9 小时前
知识库权限变了,怎样避免答错?
agent·权限管理·知识库·工作流·zgi·客服运营
大模型真好玩10 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
掰头战士10 小时前
MCP、Skill、Plugin,都是给agent拓展能力,到底有何区别?
typescript·llm·agent
嘟嘟嘟952711 小时前
AI Agent 的边缘困境
人工智能·架构·agent
HYDtomako11 小时前
Agent checkpoint设计
ai·agent·checkpoint·claude code