Agent 工程体系 · 第 37/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

MCP 认证与授权:OAuth、客户端身份、Scope、令牌和代理风险

MCP 的认证与授权,解决的不是同一个问题:

  • **认证(Authentication)**回答“你是谁”;
  • **授权(Authorization)**回答“你能做什么”;
  • **客户端身份(Client Identity)**回答“是哪一个 MCP 客户端发起了 OAuth 请求”;
  • Scope回答“令牌被授予了哪些粗粒度权限”;
  • **令牌(Token)**回答“调用方如何在后续请求中证明自己已经获得授权”;
  • 对象权限回答“即使令牌拥有某个 Scope,它是否有权访问这一个租户、项目、文件或订单”。

如果把这些概念混为一谈,系统通常会出现两类错误:一类是“用户已经登录,所以什么都能访问”;另一类是“令牌有效,所以 MCP Server 可以把它转发给任意下游服务”。前者造成越权,后者造成令牌受众混淆和代理型安全漏洞。

本文以 2026-07-28 MCP 规范为基线,讨论 2026 年 9 月 Agent 工程中的 HTTP MCP 认证模型,同时说明 stdio、Streamable HTTP、OAuth 客户端注册、Scope 升级、令牌受众绑定和 MCP 代理的风险边界。MCP 授权规范本身只规定 HTTP-based transport 的授权流程;stdio 不应直接套用这套 OAuth 流程,而应通过环境等本地机制取得凭据。(modelcontextprotocol.io)


一、先建立完整的安全模型

1. MCP 中有哪些参与者

MCP 体系中至少存在以下参与者:

flowchart LR
    U[Resource Owner<br/>用户或组织] --> C[Host / MCP Client]
    C -->|OAuth 授权请求| AS[Authorization Server]
    AS -->|Access Token| C
    C -->|Bearer Token + MCP 请求| RS[MCP Server<br/>Resource Server]
    RS -->|下游专用凭据| API[第三方 API]

其中:

  • Resource Owner:资源所有者,通常是用户,也可能是组织或服务账户;
  • Host:承载 Agent 的应用,例如 IDE、桌面客户端、企业 Agent 平台;
  • MCP Client:Host 内部连接一个 MCP Server 的连接器;
  • Authorization Server,AS:负责用户登录、同意和签发令牌;
  • MCP Server:在 OAuth 术语中扮演 Resource Server,保护工具、资源或其他能力;
  • 下游 API:MCP Server 可能访问的 Git、CRM、数据库、工单系统等外部服务。

MCP 规范中的 MCP Client 是 OAuth Client,受保护的 MCP Server 是 OAuth Resource Server,Authorization Server 负责与用户交互并签发供 MCP Server 使用的访问令牌。Authorization Server 可以与 MCP Server 部署在一起,也可以是独立服务。(modelcontextprotocol.io)

这里有一个容易忽略的事实:

MCP Client 不一定等于最终用户,也不一定等于 Agent。

一个 Host 可能有多个 MCP Client,分别连接不同的服务器。一个 MCP Client 又可能代表用户、代表企业租户,或者代表一个后台服务账户。因此,“客户端身份”和“用户身份”必须分开建模。


2. 认证与授权的逻辑关系

可以用一个授权判断函数描述 MCP Server 是否允许一次调用:

Allow=AuthenticatedAudienceValidScopeSufficientObjectPolicySatisfiedActionPolicySatisfiedAllow = Authenticated \land AudienceValid \land ScopeSufficient \land ObjectPolicySatisfied \land ActionPolicySatisfied

各变量含义如下:

  • Authenticated:令牌签发者、签名、有效期等验证通过;
  • AudienceValid:令牌确实是签发给当前 MCP Server 的;
  • ScopeSufficient:令牌的 Scope 覆盖本次操作;
  • ObjectPolicySatisfied:调用者对目标对象有权限,例如只能访问当前租户;
  • ActionPolicySatisfied:当前工具调用满足二次确认、审批、风险等级等业务条件。

这几个条件是“与”关系,而不是“或”关系。即使令牌有效,只要对象权限不满足,也必须拒绝。

例如:

用户 Alice
租户 tenant-a
令牌 Scope: files:read
目标对象: tenant-b/project-7/report.xlsx
操作: read

令牌认证成功,且 files:read 足够执行“读文件”这一类动作,但:

tenant-a != tenant-b

所以最终结果仍然应当是拒绝。

反例是只写如下代码:

if "files:read" in token.scopes:
    return read_file(request.file_id)

这段逻辑只实现了 Scope 检查,没有检查租户、对象归属和用户对对象的关系。攻击者只需要把 file_id 替换成另一个租户的 ID,就可能读到不属于自己的数据。


二、MCP 授权覆盖哪些传输

1. stdio:本地进程边界,不是远程 OAuth 边界

stdio 是由客户端启动 MCP Server 子进程,双方通过标准输入和标准输出传递换行分隔的 JSON-RPC 消息。2026-07-28 规范将 stdio 定义为本地进程通信绑定;在这种传输下,MCP 授权规范建议不要使用 HTTP OAuth 流程,而应从环境中读取凭据。(modelcontextprotocol.io)

典型启动方式如下:

MCP_API_TOKEN='token-for-local-server' \
MCP_TENANT_ID='tenant-a' \
./my-mcp-server

MCP Server 可以从环境变量读取:

import os

api_token = os.environ["MCP_API_TOKEN"]
tenant_id = os.environ["MCP_TENANT_ID"]

这里的安全边界是:

Host 启动子进程
  └── 环境变量和进程权限
        └── stdio MCP Server

它没有经过浏览器,也没有 HTTP Authorization Header,更没有 MCP 规范定义的 OAuth Discovery 流程。

stdio 适合:

  • 单机工具;
  • IDE 插件;
  • 由可信 Host 启动的文件系统或代码分析服务;
  • 凭据已经由操作系统、密钥管理器或 Host 管理的场景。

但 stdio 并不意味着“天然安全”。如果 Host 会加载不可信配置,或者允许任意 MCP Server 命令,那么环境变量、文件路径、子进程权限和命令行参数都会变成攻击面。

尤其不要把长期高权限令牌直接拼在命令行参数中:

./server --token sk-production-secret

命令行参数可能出现在进程列表、诊断信息或崩溃报告中。环境变量也不是万能的,但通常比命令行参数更不容易被普通进程列表直接暴露。生产系统仍应优先使用操作系统密钥环、短期令牌或受限的本地凭据代理。


2. Streamable HTTP:令牌位于 HTTP 请求边界

Streamable HTTP 将每条 MCP 消息作为发往单一 MCP Endpoint 的 HTTP 请求,响应可以是 JSON,也可以是请求范围内的 SSE 流。2026-07-28 版本的传输层使用请求级模型,协议元数据位于消息体的 _meta 中,部分字段可以镜像到 HTTP Header,供网关路由和检查;消息体仍然是事实来源。(modelcontextprotocol.io)

授权令牌则必须放在 HTTP Header:

POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Authorization: Bearer eyJ...
MCP-Protocol-Version: 2026-07-28

MCP 客户端必须在发往 MCP Server 的每个 HTTP 请求中携带授权信息,访问令牌不得放入 URI 查询字符串。(modelcontextprotocol.io)

使用 curl 可以观察最小请求形态:

curl --fail-with-body \
  -X POST 'https://mcp.example.com/mcp' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJhbGciOi...' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {
      "_meta": {
        "io.modelcontextprotocol/clientInfo": {
          "name": "example-host",
          "version": "1.0.0"
        }
      }
    }
  }'

这个请求能成立,需要同时满足:

  1. URL 指向真正的 MCP Endpoint;
  2. HTTPS 证书和域名验证通过;
  3. Bearer Token 未过期;
  4. Token 的签发者是 MCP Server 认可的 Authorization Server;
  5. Token 的受众包含当前 MCP Server;
  6. Token Scope 满足 tools/list 或相关资源策略;
  7. MCP Server 能正确解析 JSON-RPC 消息。

其中 clientInfo 是客户端自报信息,主要用于显示、日志和调试,不能被当作安全凭据。客户端在请求中声明自己叫 "example-host",并不代表服务端已经确认了这个身份。SDK 文档也明确将 clientInfoserverInfo 视为 self-reported 信息,不应用于安全决策。(ts.sdk.modelcontextprotocol.io)


三、OAuth 流程不是“拿到一个 Token 就结束”

1. 第一步:从 MCP Server 发现 Authorization Server

MCP Server 必须提供 OAuth Protected Resource Metadata,用来声明其关联的 Authorization Server。客户端可以通过 401 Unauthorized 响应中的 WWW-Authenticate Header 发现元数据地址,也可以访问规定的 .well-known/oauth-protected-resource 地址。(modelcontextprotocol.io)

例如,MCP Server 返回:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
  scope="files:read"

客户端应当先请求:

curl --fail \
  'https://mcp.example.com/.well-known/oauth-protected-resource'

可能得到:

{
  "resource": "https://mcp.example.com",
  "authorization_servers": [
    "https://auth.example.com"
  ],
  "scopes_supported": [
    "files:read",
    "files:write"
  ]
}

这里有两个不同的字段:

  • authorization_servers:哪些 Authorization Server 可以给该 MCP Server 签发令牌;
  • scopes_supported:该资源服务器声明支持的 Scope 集合。

scopes_supported 不一定包含运行时所有动态 Scope,也不一定等同于某一次 401403 Challenge 中要求的 Scope。运行时 Challenge 对当前操作更具针对性,客户端不能假设二者存在固定的子集或超集关系。(modelcontextprotocol.io)

客户端随后请求 Authorization Server Metadata。对于带路径的 issuer,例如:

https://auth.example.com/tenant-a

客户端需要按规范尝试多个 well-known 地址,并校验元数据中的 issuer 是否与实际使用的 issuer 标识完全一致。若从攻击者地址获取到的文档声称 issuer 是另一个地址,客户端必须拒绝使用该元数据。(modelcontextprotocol.io)


2. 第二步:确定客户端身份

OAuth Client ID 表示“哪个应用发起授权”。它不等于用户 ID,也不等于 Access Token。

MCP 支持三类客户端注册方式:

  1. 预注册:服务端和客户端提前建立关系;
  2. Client ID Metadata Document,CIMD:客户端使用一个 HTTPS URL 作为 client_id,该 URL 指向客户端元数据文档;
  3. Dynamic Client Registration,DCR:客户端向 Authorization Server 的注册端点动态注册。

2026-07-28 基线中,CIMD 是无预先关系场景下的首选方式;DCR 已弃用,但为兼容旧 Authorization Server 仍可使用。客户端若支持多种方式,应优先使用预注册,然后使用 CIMD,再以 DCR 作为回退。(modelcontextprotocol.io)

一个 CIMD 文档可以是:

{
  "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"
}

要求重点在于:

  • client_id 必须是 HTTPS URL;
  • URL 必须有路径,不能只是站点根地址;
  • 文档中的 client_id 必须与文档 URL 完全匹配;
  • 必须声明 client_nameredirect_uris
  • Authorization Server 必须校验授权请求中的 Redirect URI 是否与文档内容匹配。(modelcontextprotocol.io)

CIMD 的关键变化是:客户端不再对每一个陌生 Authorization Server 发起一次注册请求,而是发布一份可被 Authorization Server 获取的客户端元数据。这样更适合 MCP 的“客户端和服务器经常没有预先关系”的生态。

但 CIMD 不等于签名证明。Authorization Server 获取一个 HTTPS JSON 文档,并不自动证明请求者控制了这个客户端。服务端仍需实施域名信任策略、文档缓存策略、SSRF 防护和 Redirect URI 校验。


3. 第三步:使用 Authorization Code 和 PKCE

典型的授权码流程包含:

sequenceDiagram
    participant C as MCP Client
    participant M as MCP Server
    participant AS as Authorization Server
    participant U as User

    C->>M: 请求 MCP 资源,无 Token
    M-->>C: 401 + resource_metadata + scope
    C->>M: 获取 Protected Resource Metadata
    M-->>C: authorization_servers + scopes_supported
    C->>AS: 获取 Authorization Server Metadata
    AS-->>C: authorize/token endpoint + PKCE 能力
    C->>AS: authorization request + state + code_challenge + resource + scope
    AS->>U: 登录与授权确认
    U-->>AS: 同意
    AS-->>C: code + state + iss
    C->>AS: token request + code_verifier + resource
    AS-->>C: access_token + 可选 refresh_token
    C->>M: Authorization: Bearer access_token
    M-->>C: MCP 响应

PKCE 是授权码保护机制。客户端生成:

  • code_verifier:只有客户端知道的随机值;
  • code_challenge:由 code_verifier 计算得到,通常使用 S256

授权请求发送 code_challenge,换取 Token 时发送 code_verifier。Authorization Server 只有在二者匹配时才兑换授权码。

直觉上,授权码可能被攻击者截获,但攻击者没有 code_verifier,所以不能把它兑换成令牌。MCP 客户端必须实现 PKCE,并在确认 Authorization Server 支持 PKCE 后才继续;技术上可行时必须使用 S256。(modelcontextprotocol.io)

一个简化的参数示例:

GET /authorize?
  response_type=code
  &client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient-metadata.json
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback
  &scope=files%3Aread
  &resource=https%3A%2F%2Fmcp.example.com
  &state=random-state
  &code_challenge=base64url-sha256(verifier)
  &code_challenge_method=S256

其中 resource 很重要。MCP 客户端必须在授权请求和 Token 请求中都带上 resource,并指明要访问的 MCP Server 的规范 URI。(modelcontextprotocol.io)


四、客户端身份不等于令牌受众

这是 MCP OAuth 中最容易被错误实现的一组关系。

设:

client_id = client-A
issuer    = https://auth.example.com
resource  = https://mcp.example.com
subject   = user-123
scope     = files:read

它们表达的是不同维度:

字段 说明
client_id 哪个 OAuth 客户端发起授权
issuer 哪个 Authorization Server 签发令牌
subject 令牌代表哪个用户或主体
resource / aud 令牌准备给哪个资源服务器使用
scope 令牌获得哪些粗粒度权限
业务声明 租户、组、项目、对象范围等

一个“有效”的令牌至少要满足:

ValidToken=SignatureValidIssuerTrustedNotExpiredAudience=MCPServerScopeRequiredScopeValidToken = SignatureValid \land IssuerTrusted \land NotExpired \land Audience = MCPServer \land Scope \supseteq RequiredScope

其中:

  • SignatureValid:签名和密钥验证成功;
  • IssuerTrusted:签发者是该 MCP Server 配置或发现得到的可信 issuer;
  • NotExpired:未超过有效期;
  • Audience = MCPServer:令牌的受众是当前 MCP Server;
  • Scope ⊇ RequiredScope:权限集合覆盖本次操作需求。

MCP Server 必须验证令牌是专门签发给自己的,不能因为令牌是同一身份系统签发的,就接受它访问其他资源。MCP Client 也不得把一个 Authorization Server 签发的凭据随意发送给另一个 Authorization Server 管理的资源。(modelcontextprotocol.io)

因此,下面这种做法是错误的:

# 错误:只验证签名和 issuer
claims = verify_jwt(token, issuer="https://auth.example.com")

if claims["scope"].find("files:read") >= 0:
    return read_file()

正确逻辑至少应包含受众和对象授权:

claims = verify_jwt(
    token,
    issuer="https://auth.example.com",
    audience="https://mcp.example.com"
)

require_scope(claims, "files:read")
require_tenant_access(claims, requested_tenant_id)
require_object_access(claims["sub"], requested_file_id)

实际系统中,JWT 验证库的参数名称会因语言和库而不同,但验证维度不能省略。


五、Scope 是粗粒度能力,不是完整对象权限

1. Scope 的含义

Scope 是 OAuth 令牌中的权限字符串集合,例如:

files:read files:write

它通常表达:

  • 是否可以读文件;
  • 是否可以写文件;
  • 是否可以创建工单;
  • 是否可以执行某类工具。

Scope 适合表达“能力类型”,不适合独立表达完整的业务权限。

例如:

files:read

最多表示调用者具备某类文件读取能力,但不自然地表达:

只能读取 tenant-a;
只能读取 project-7;
只能读取用户本人创建的文件;
不能读取标记为 confidential 的文件;
需要用户在本次操作中再次确认。

所以,完整授权通常分层进行:

OAuth Scope
    ↓
主体与租户关系
    ↓
对象归属和对象级 ACL
    ↓
操作风险与二次确认

2. 初始 Scope 选择

MCP Server 可以在 401WWW-Authenticate Header 中给出当前资源需要的 Scope:

WWW-Authenticate: Bearer
  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
  scope="files:read"

客户端选择初始 Scope 时,应优先使用 Challenge 中的 Scope;如果没有 Challenge Scope,再参考 Protected Resource Metadata 中的 scopes_supported。客户端不应一开始就请求所有高权限 Scope,因为这会扩大用户授权面,并增加令牌泄漏后的影响范围。(modelcontextprotocol.io)

可以把初始权限集合记为:

S0=ChallengeScopesS_0 = ChallengeScopes

若不存在 Challenge Scope,则:

S0=SupportedScopesS_0 = SupportedScopes

但这只是起始集合,不代表未来所有操作都能完成。


3. 运行时 Scope Challenge 和 Step-Up

假设客户端已有:

Token Scope = files:read

此时 Agent 调用了删除工具:

{
  "method": "tools/call",
  "params": {
    "name": "delete_file",
    "arguments": {
      "file_id": "f-100"
    }
  }
}

MCP Server 发现该操作需要 files:write,应返回类似:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer
  error="insufficient_scope",
  scope="files:write",
  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
  error_description="File write permission required for this operation"

客户端不能简单地把旧 Scope 替换掉。正确的 Scope 集合是并集:

Snew=SoldSchallengeS_{new} = S_{old} \cup S_{challenge}

在这个例子中:

S_old       = { files:read }
S_challenge = { files:write }

S_new       = { files:read, files:write }

如果客户端只请求 files:write,新令牌可能不再包含 files:read,导致之前可用的操作突然失败。MCP 规范要求客户端在 Step-Up 时累计旧权限与当前 Challenge 权限,并限制重新授权和重试次数,避免失败循环。(modelcontextprotocol.io)

服务端也应一次性返回当前操作所需的全部 Scope:

错误做法:
第一次返回 files:write
第二次返回 files:delete
第三次返回 files:admin

正确做法:
一次返回 files:write files:delete

否则一次操作会被拆成多轮授权,用户体验更差,也增加授权状态机出错的概率。


六、访问令牌、刷新令牌和令牌存储

1. Access Token

Access Token 是客户端访问 MCP Server 时使用的凭据。对于 HTTP MCP,形式是:

Authorization: Bearer <access-token>

它通常具有较短有效期,并携带 Scope、受众、主体、签发时间等信息。MCP Server 必须在处理 MCP 请求前完成令牌验证;无效或过期令牌应返回 401。无效 Scope 或权限不足通常返回 403。(modelcontextprotocol.io)

不要把 Access Token 放在:

URL 查询参数
Referer
普通业务日志
MCP 工具参数
模型上下文
错误消息

错误示例:

https://mcp.example.com/mcp?access_token=eyJ...

查询字符串可能进入代理访问日志、浏览器历史、链路追踪、监控标签和缓存键。MCP 规范明确禁止将 Access Token 放入 URI 查询字符串。(modelcontextprotocol.io)


2. Refresh Token

Refresh Token 用于在 Access Token 过期后获取新令牌。它的权限和生命周期通常比 Access Token 更敏感,因为持有者可能长期维持用户授权。

MCP 客户端如果需要 Refresh Token:

  • 必须安全存储和传输;
  • 可以在客户端元数据中声明支持 refresh_token grant;
  • 不能假设 Authorization Server 一定会签发;
  • 对公开客户端,应使用刷新令牌轮换。(modelcontextprotocol.io)

一个合理的存储分层是:

Access Token  -> 内存或短期加密缓存
Refresh Token -> 操作系统密钥环 / KMS / 加密数据库
客户端密钥     -> 仅限机密客户端,不能放入前端

对于桌面应用、CLI 或本地 Agent,通常属于 public client,不能依赖“客户端秘密不泄漏”来保护授权流程。因为安装包中的 secret 最终可以被用户或攻击者提取,真正的保护应依靠 PKCE、精确 Redirect URI、短期 Access Token 和刷新令牌轮换。


七、授权响应中的 iss:防止 Authorization Server Mix-Up

1. 什么是 Mix-Up

假设客户端同时连接两个 MCP Server:

MCP-A -> AS-A
MCP-B -> AS-B

攻击者控制 AS-B,并诱导客户端把本应发送给 AS-A 的授权响应交给 AS-B。若客户端只根据 statecode 处理响应,而不检查授权响应来自哪个 issuer,就可能把授权码提交到错误的 Token Endpoint。

这就是 Authorization Server Mix-Up。

2. MCP 的校验方式

客户端在开始授权时,应记录经过验证的 issuer:

authorization_request_id = r-001
expected_issuer = https://auth.example.com/tenant-a
state = random-state
code_verifier = random-verifier

回调收到:

code=abc
state=random-state
iss=https://auth.example.com/tenant-a

客户端需要:

  1. 校验 state
  2. 将回调中的 iss 与之前记录的 issuer 做字符串比较;
  3. 不进行大小写、默认端口、尾部斜杠或百分号编码等“宽松规范化”;
  4. 校验通过后,才把 code 发给 Token Endpoint。

2026-07-28 规范要求客户端按照 RFC 9207 规则验证 iss。如果 Authorization Server 元数据声明支持 iss,但响应缺少 iss,客户端必须拒绝;如果响应带有 iss,客户端也必须与已记录 issuer 比较。(modelcontextprotocol.io)

反例:

# 错误:只要 state 正确就使用 code
if callback["state"] == saved_state:
    exchange_code(callback["code"])

更安全的逻辑是:

if callback["state"] != saved_state:
    raise AuthorizationError("state mismatch")

if callback.get("iss") != expected_issuer:
    raise AuthorizationError("issuer mismatch")

exchange_code(
    code=callback["code"],
    code_verifier=saved_code_verifier,
    resource="https://mcp.example.com"
)

如果服务端尚未提供 iss,客户端应依据其元数据中的 authorization_response_iss_parameter_supported 决定是否接受缺失值;生产实现不应为了兼容而无条件关闭 issuer 校验。


八、MCP 代理最危险的问题:Token Passthrough

1. 代理拓扑

考虑如下架构:

MCP Client
    |
    | Token T_client-for-proxy
    v
MCP Proxy
    |
    | Downstream API
    v
Third-party API

客户端给 Proxy 的令牌,是为 Proxy 签发的:

aud(T_client) = MCP Proxy

Proxy 再访问下游 API 时,应该取得一个新的、面向下游 API 的令牌:

aud(T_downstream) = Third-party API

正确的数据流是:

T_client-for-proxy
    --验证-->
Proxy 内部身份与授权
    --重新授权或凭据交换-->
T_downstream-for-api

错误的数据流是:

T_client-for-proxy
    --原样转发-->
Third-party API

这叫 Token Passthrough,即令牌透传。MCP 规范明确禁止 MCP Server 将收到的客户端令牌原样传递给上游服务;上游调用应使用由上游 Authorization Server 签发的独立令牌。(modelcontextprotocol.io)

2. 为什么令牌透传危险

令牌的安全属性包括:

issuer
audience
subject
scope
expiry

Proxy 收到的令牌通常满足:

audience = Proxy

如果下游 API 接受它,实际上就意味着:

下游 API 信任一个并非为自己签发的令牌

这会造成至少三个问题:

  1. 受众混淆:同一令牌被多个资源服务器接受;
  2. 权限扩大:Proxy 侧的 Scope 语义可能被误解释为下游权限;
  3. 信任边界泄漏:下游 API 被迫理解上游身份系统和 MCP 客户端的令牌格式。

正确的代理授权应当明确区分:

用户是否可以调用 Proxy 的工具
Proxy 是否可以访问下游 API
用户对下游对象是否有权限

这三个问题需要分别判断。


九、Confused Deputy:代理有权限,不代表用户有权限

Confused Deputy 可以理解为“有权限的代理被低权限调用者诱导,替调用者执行了本不该执行的动作”。

例如:

MCP Proxy 拥有企业级 GitHub Token
用户 Bob 只能访问仓库 A
Agent 被诱导调用 Proxy 的 read_repository(repo=B)
Proxy 使用自己的企业 Token 读取仓库 B

如果 Proxy 只验证:

Proxy 自己能不能访问 repo=B

而没有验证:

Bob 是否能访问 repo=B

那么 Proxy 就成为了权限放大器。

形式化地说,代理调用下游 API 的允许条件应是:

Allowproxy=AllowclientproxyAllowproxydownstreamAllowuserobjectAllow_{proxy} = Allow_{client \to proxy} \land Allow_{proxy \to downstream} \land Allow_{user \to object}

不能简化为:

Allowproxy=AllowproxydownstreamAllow_{proxy} = Allow_{proxy \to downstream}

MCP 规范将“作为第三方 API 中介的 MCP Server”列为 Confused Deputy 风险场景,并要求使用静态客户端身份的代理,在向第三方 Authorization Server 转发动态注册客户端之前取得用户同意。(modelcontextprotocol.io)

工程上,代理至少要保留以下上下文:

{
  "actor": "user-123",
  "tenant": "tenant-a",
  "client_id": "host-client-1",
  "mcp_server": "https://proxy.example.com",
  "tool": "read_repository",
  "object": {
    "type": "repository",
    "id": "repo-b"
  },
  "scopes": ["repo:read"]
}

然后在真正访问下游对象前执行对象级授权,而不是仅仅把 actor 写进日志。


十、代理、网关和 WAF 的 Header 风险

2026-07-28 Streamable HTTP 支持将部分 MCP 元数据镜像到 HTTP Header,使网关可以按方法和工具路由、限流或审计;但消息体仍是事实来源,Header 与 Body 不一致时应拒绝请求。(modelcontextprotocol.io)

例如:

Mcp-Method: tools/call
Mcp-Name: delete_file

消息体中却是:

{
  "method": "tools/call",
  "params": {
    "name": "read_file"
  }
}

如果网关按 Header 判断“这是一个低风险读操作”,而后端按 Body 执行删除操作,就会产生授权绕过。

正确的处理顺序是:

1. 网关解析并规范化 Header
2. 后端解析 Body
3. 比较 Header 与 Body
4. 不一致则拒绝
5. 仅在一致后执行路由、审计和授权

类似风险还包括:

  • 代理删除 Authorization Header,导致客户端不断重复授权;
  • 代理把用户 Token 写入下游请求;
  • 网关缓存带有用户数据的 MCP 响应;
  • WAF 记录完整 Bearer Token;
  • 反向代理修改 Host、Path 或 Scheme,导致 Resource URI 不一致;
  • 多租户网关按 Host 路由,但 Token 的 aud 仍指向另一个租户服务;
  • 重试中间件在令牌过期后自动重放带副作用的工具调用。

对于带副作用的工具,例如:

delete_file
send_email
create_payment
rotate_secret

HTTP 重试不能只依据“网络错误”。必须结合请求是否已经到达后端、是否具有幂等键、是否完成用户确认和是否产生业务结果进行判断。


十一、CIMD 的 SSRF 与 localhost 风险

CIMD 要求 Authorization Server 获取客户端提供的 HTTPS 元数据 URL。这会把“服务端主动发起 HTTP 请求”引入 OAuth 注册流程,因此带来 SSRF 风险。

攻击者可能提交:

https://attacker.example/client.json

文档内容再诱导 Authorization Server 访问:

http://169.254.169.254/latest/meta-data/

或者访问内部管理端口。

Authorization Server 获取 CIMD 时应考虑:

  • 禁止访问云实例元数据地址;
  • 限制内网 IP、回环地址和链路本地地址;
  • 避免无限重定向;
  • 限制响应大小和超时;
  • 校验 Content-Type 与 JSON 结构;
  • 对域名解析结果重新检查;
  • 按组织策略限制允许的客户端域名;
  • 缓存时尊重 HTTP Cache Header,但不能永久信任远程文档。

规范明确要求 Authorization Server 处理 CIMD 时考虑 SSRF 防护。(modelcontextprotocol.io)

另一个风险是 localhost Redirect URI:

http://localhost:3000/callback

本地应用常用这种回调地址,但“localhost”并不证明回调一定属于原始客户端。另一个本地进程可能抢占端口、监听回调,或者诱导用户向错误应用授权。

因此 Authorization Server 至少应:

  • 在授权页明确显示 Redirect URI 的主机名;
  • 对仅使用 localhost 的客户端显示额外提醒;
  • 精确校验完整 Redirect URI;
  • 不允许任意路径和任意端口的模糊匹配。

MCP 规范要求 Redirect URI 使用 HTTPS 或 localhost,并要求授权服务器对已注册 URI 进行精确校验。(modelcontextprotocol.io)


十二、错误状态码与诊断路径

1. 401 Unauthorized

401 通常表示:

  • 没有发送令牌;
  • 令牌格式错误;
  • 令牌过期;
  • 签发者不可信;
  • 受众不匹配;
  • 令牌无法验证。

服务端应通过 WWW-Authenticate 提供资源元数据地址,必要时给出初始 Scope。

诊断示例:

curl -i -X POST 'https://mcp.example.com/mcp' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

预期应看到:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="..."

如果直接返回 HTML 登录页,客户端可能无法完成 MCP OAuth Discovery。


2. 403 Forbidden

403 通常表示令牌已经被识别,但权限不足:

  • Scope 不足;
  • 用户没有目标租户权限;
  • 用户没有目标对象权限;
  • 工具需要二次确认;
  • 组织策略禁止当前操作。

运行时 Scope 不足时,应返回:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer
  error="insufficient_scope",
  scope="files:write",
  resource_metadata="..."

不要把所有授权错误都返回 401。如果客户端已经有有效令牌,却因为 Scope 不足得到 401,客户端可能错误地重新执行完整登录,而不是进行增量授权。


3. 常见故障定位表

表现 常见原因 首先检查
每次请求都触发登录 Token 没被代理转发 Authorization Header 是否到达 MCP Server
Token 有效但总是 403 Scope、租户或对象权限不足 scopetenant_id、对象 ACL
Token 在 MCP Server 有效,在下游失败 错误地透传了 MCP Token 下游 API 的 aud 是否匹配
授权回调报 issuer mismatch 多 Authorization Server 混用或代理改写 记录的 issuer、回调 iss、元数据 issuer
localhost 回调偶发失败 端口冲突或本地进程拦截 Redirect URI、监听端口、进程所有者
注册请求超时 CIMD 获取失败或被安全网关拦截 客户端元数据 URL、DNS、SSRF 防护
Agent 重复执行删除 HTTP 自动重试副作用请求 幂等键、响应确认、重试策略
网关审计显示 read,后端执行 write Header 与 Body 不一致 Mcp-MethodMcp-Name 与 JSON-RPC 内容

生产日志中可以记录:

issuer
client_id
subject hash
resource
audience
scope hash
tenant_id
tool name
object type/id hash
authorization result

不应记录:

完整 Access Token
完整 Refresh Token
授权码
PKCE code_verifier
包含敏感数据的工具参数

十三、一个端到端的最小实现检查

一个支持 HTTP OAuth 的 MCP Client,在第一次请求时可以按如下顺序工作:

1. 向 MCP Endpoint 发起请求
2. 收到 401
3. 解析 WWW-Authenticate
4. 获取 Protected Resource Metadata
5. 选择 Authorization Server
6. 获取并验证 Authorization Server Metadata
7. 确定客户端注册方式
8. 验证 PKCE 支持
9. 生成 state 和 code_verifier
10. 携带 resource 和最小 Scope 发起授权
11. 校验回调 state 和 iss
12. 使用 code_verifier 兑换 Token
13. 以 Authorization Header 调用 MCP
14. 收到 403 insufficient_scope 时计算 Scope 并集
15. 重新授权并限制重试次数
16. 令牌刷新失败时清除无效凭据并重新开始 Discovery

MCP Server 的对应检查顺序是:

1. 读取 Authorization Header
2. 验证 Bearer 格式
3. 验证 issuer、签名和有效期
4. 验证 audience/resource
5. 提取 subject、租户和 Scope
6. 根据工具和参数计算所需 Scope
7. 执行租户和对象权限检查
8. 执行二次确认或审批策略
9. 如访问下游,使用下游专用令牌
10. 返回 MCP 响应或标准 HTTP 授权错误

注意第 8 步不能被 Scope 替代。files:write 可以表示“用户具备写文件能力”,但不一定表示“删除生产环境文件无需二次确认”。


十四、规范保证、常见实现和工程建议

规范保证

在 2026-07-28 基线下,以下属于 MCP 授权规范中的关键要求:

  • HTTP 授权流程基于 OAuth 2.1 相关机制;
  • MCP Server 必须提供 Protected Resource Metadata;
  • 客户端必须支持 Authorization Server Discovery;
  • 客户端必须在授权请求和 Token 请求中携带 resource
  • Access Token 必须通过 Authorization: Bearer Header 发送;
  • Access Token 不得出现在 URI 查询字符串;
  • MCP Server 必须验证令牌受众;
  • MCP Server 不得透传收到的客户端令牌;
  • 客户端必须使用 PKCE 并验证其支持;
  • 客户端必须根据规则校验授权响应中的 iss
  • DCR 已弃用,CIMD 是新实现的优先方向。(modelcontextprotocol.io)

常见实现

以下能力常见于 SDK 或 OAuth 基础设施,但不应直接当作协议保证:

  • JWT 格式的 Access Token;
  • JWKS 远程公钥验证;
  • 自动刷新 Access Token;
  • 自动处理 insufficient_scope
  • 浏览器回调服务器;
  • Redis 或数据库中的 Token 缓存;
  • API Gateway 统一校验令牌;
  • OIDC 作为 Authorization Server 的身份层。

MCP 官方 SDK 页面显示,TypeScript、Python、C#、Go、Rust 等 SDK 属于 Tier 1,SDK 普遍支持本地和远程传输;具体 OAuth API、版本和默认行为仍应以所用 SDK 的版本文档为准。(modelcontextprotocol.io)

工程建议

工程上可以采用以下边界:

OAuth:
    负责主体认证、客户端注册、粗粒度能力

MCP Server:
    负责 Token 验证、受众验证、Scope 检查

业务授权层:
    负责租户、项目、对象、关系和状态检查

风险控制层:
    负责二次确认、审批、幂等、审计和撤销

下游连接器:
    负责获取并使用下游专用令牌

这比把所有逻辑压缩到一段 verify_token() 中更容易审计,也更容易定位失败原因。


结语:MCP 授权的最小正确答案

一个 MCP 请求是否安全,不能只问:

Token 有效吗?

至少要连续回答:

1. 令牌是谁签发的?
2. 令牌是给谁使用的?
3. 客户端身份是什么?
4. 令牌代表哪个主体?
5. 主体属于哪个租户?
6. Scope 是否覆盖当前动作?
7. 主体是否有权访问当前对象?
8. 当前工具是否需要二次确认?
9. MCP Server 是否错误透传了令牌?
10. 代理是否把自己的权限误当成用户权限?

OAuth 解决的是跨网络的授权委托,Client ID 解决的是客户端识别,Scope 解决的是能力分类,Token 解决的是请求证明;它们都不能单独完成对象级授权。

在 Agent 系统中,真正的安全边界应当是:

有效令牌可以调用任意工具可以访问任意对象可以让代理代表用户访问任意下游服务\text{有效令牌} \neq \text{可以调用任意工具} \neq \text{可以访问任意对象} \neq \text{可以让代理代表用户访问任意下游服务}

只有把身份、受众、Scope、租户、对象和代理上下文同时保留下来,MCP 的认证与授权才不会从“登录流程”退化成一个看似有效、实际缺少权限边界的 Bearer Token 转发系统。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。