MCP 协议设计与实现

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

作者 杨艺韬 · 8,947 字 · 发布于 · 更新于

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、一份 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 为例):

  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:

// 文件: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 规范要求授权服务器至少提供其中一种,客户端则两种都必须支持:

  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,优先级清晰:

// 文件: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)的优先级顺序是:

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

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 规范原文对此有明确的警告:

攻击者可以:

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

授权服务器看到的是合法客户端的元数据文档,用户看到的是合法客户端的名称——攻击很难被察觉。这就是为什么 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 的说明。)

这里有两种接入方式:

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

  1. 使用 WWW-Authenticate 头中的 scope 参数
  2. 如果没有,使用受保护资源元数据中 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)

两条值得记住的工程对比——

  1. TS 把发现逻辑按调用层级自上而下排在 auth.ts 的中后段,编排入口 discoverOAuthServerInfo 在最后;Python 把 URL 构造、响应处理拆成 utils.py 里的小函数(最长的也不到 45 行,命名按「动词_名词」:extract / build / handle / create / is / should),但把请求编排整段内联在 async_auth_flow 里——与 §3.8.3 末尾谈到的两边代码组织差异一脉相承。
  2. Python utils.py 339 行 + oauth2.py 646 行 = 985 行;TS auth.ts 1745 行,同口径下 TS 约为 Python 的 1.8 倍(1.77)。§15.9.4 的「2.7 倍」只拿 oauth2.py 646 行作分母;差距主要不在发现逻辑本身,而在 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 选择了安全。