前言
随着 MCP(Model Context Protocol)逐渐从本地走向真实业务,越来越多的 MCP Server 开始连接用户数据、企业系统和第三方服务。此时,一个无法回避的问题随之出现:如何让 AI 安全地代表用户访问这些资源?
在简单的开发环境中,我们可以直接配置 API Key 或访问令牌。但进入多用户、跨平台的生产环境后,这种方式很快就会暴露问题:凭证如何安全保存?不同用户如何获得不同权限?访问令牌过期后如何续期?用户又该怎样撤销已经授予的权限?这些问题,正是 OAuth 授权机制试图解决的。
对 MCP Server 而言,OAuth 不只是增加一个「登录页面」。它涉及客户端发现受保护资源、定位授权服务器、发起用户授权、获取访问令牌,以及携带令牌调用 MCP Server 等一整套流程。只有理解各个参与方的角色与交互关系,才能真正搭建出安全、可扩展且符合规范的 MCP 授权体系。
本文将从 MCP Server 为什么需要 OAuth 开始,逐步拆解授权流程中的核心角色、Token 流转方式和关键安全机制,并结合实际接入场景说明常见问题与实现要点。希望读完本文后,你不仅知道 MCP OAuth「怎么接」,也能理解它「为什么要这样设计」。
当 MCP Client 第一次访问受保护的远程 MCP Server 时,它如何知道去哪授权、如何获得 Token,以及 MCP Server 为什么相信这个 Token?
先划清适用范围:这一整套 OAuth 流程是为 HTTP 类传输(也就是远程托管的 MCP Server)设计的。如果你的 Server 走的是 STDIO 传输(由客户端在本地拉起子进程),官方明确推荐直接用环境变量注入凭证,不需要浏览器授权流。别把远程方案照搬到本地项目上白折腾。
为什么 MCP Server 需要 OAuth?
假设我们开发了一个企业知识库 MCP Server,并向客户端提供了一个 search_documents 工具。用户可以通过 VS Code、Cursor 等 MCP Client,让 AI 查询企业内部文档。
在本地开发阶段,我们可能会直接在环境变量中配置一个 API Key。因为服务器只有开发者自己使用,所以暂时不需要区分「谁在调用」「能够访问哪些文档」。
但当 MCP Server 部署到远程服务器,并开放给多名用户使用后,问题就出现了:
- MCP Server 如何知道当前请求属于哪位用户?
- 不同用户是否可以访问不同的文档?
- 用户如何只授予「读取文档」权限,而不授予「修改文档」权限?
- Access Token 泄露或员工离职后,权限如何撤销?
- 系统如何审计某个操作是由谁发起的?
如果仍然让所有用户共享同一个 API Key,MCP Server 就无法可靠地区分用户,也很难实现权限隔离、访问撤销和操作审计。
OAuth 解决的正是这类问题。它允许用户在授权服务器中完成登录和授权,由授权服务器向 MCP Client 签发一个权限受限、具有有效期,并且只能用于指定 MCP Server 的 Access Token。MCP Client 不需要获得用户密码,只需要携带这个 Token 请求 MCP Server。
需要注意的是,OAuth 主要解决的是委托授权问题。用户身份认证通常发生在授权服务器的登录环节,并可能由 OpenID Connect 等机制完成。
四个核心角色
| MCP 场景 | OAuth 角色 | 主要职责 |
|---|---|---|
| VS Code、Cursor 等 | OAuth Client | 代表用户请求 MCP Server |
| 企业知识库 MCP Server | Resource Server | 提供工具和数据,并验证 Token |
| Keycloak、Auth0 等 | Authorization Server | 用户登录、授权并签发 Token |
| 使用 AI 的员工 | Resource Owner | 决定是否授予访问权限 |
规范里的原话是:受保护的 MCP Server 扮演 OAuth 2.1 resource server,MCP Client 扮演 OAuth 2.1 client,授权服务器负责与用户交互并签发令牌——它可以和资源服务器部署在一起,也可以是完全独立的实体,其实现细节不在 MCP 规范范围内。
这里有个容易混淆的点:MCP Server 自己也会是一个 OAuth Client。当它需要调用授权服务器的 introspection 端点去验证 Token 时,它必须以客户端身份带上自己的 client_id 和 client_secret。也就是说,同一个 MCP Server 在「面向用户」时是 Resource Server,在「面向授权服务器」时是 Client。这两套凭证必须严格分开,后面会再提。
一个前提:规范明确写了「Authorization is OPTIONAL for MCP implementations」——授权对 MCP 实现是可选的。用 HTTP 类传输时应当遵循本规范;用 STDIO 传输时不应当遵循,而是从环境变量取凭证。所以下文所有内容的适用前提,都是「你确实需要授权,且走的是 HTTP 传输」。
完整授权过程:六步拆解
整套流程的设计哲学是:客户端一开始什么都不知道,先撞墙,然后顺着墙上的指路牌一层层往下发现。
这是个很聪明的设计——它意味着用户只需要在客户端里填一个 MCP Server 的 URL,剩下的授权服务器地址、支持的权限范围、注册端点,全都能自动推导出来。
第 1 步:首次请求被拒(401 挑战)
客户端不带 Token 直接请求 MCP 端点,服务器返回 401,并在 WWW-Authenticate 头里告诉它「去哪问」:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
关键在 resource_metadata 这个参数,它指向一份 Protected Resource Metadata(PRM)文档。规范对此是硬要求:MCP 服务器 MUST 实现 RFC 9728,MCP 客户端 MUST 用它来发现授权服务器。
注意这份 PRM 文档是由 MCP Server 自己托管的,不是授权服务器。路径遵循可预测的约定:/.well-known/oauth-protected-resource。
另外注意上面例子里的 scope 参数——规范建议(SHOULD)服务器在 401 挑战里直接告诉客户端「这次操作需要哪些权限」,依据是 RFC 6750 Section 3。这么做的价值是让客户端按最小权限申请,而不是一上来就把 scopes_supported 里的权限全要一遍。
客户端选 scope 的优先级是:① 优先用 401 挑战里给的 scope;② 没给才退回用 PRM 里的 scopes_supported。 而且规范特别强调,挑战里的 scope 集合和 scopes_supported 可能是子集、超集,也可能两者都不是,客户端不得假设任何包含关系,必须把挑战里的当权威。
第 2 步:拉取 PRM 文档
客户端按上一步给的地址取回 JSON:
{
"resource": "https://your-server.com/mcp",
"authorization_servers": ["https://auth.your-server.com"],
"scopes_supported": ["mcp:tools", "mcp:resources"]
}
三个字段各有用处:
resource—— 资源标识符,后续 audience 校验就是拿它做基准authorization_servers—— 授权服务器列表,可以有多个,客户端自行挑一个用scopes_supported—— 支持的权限范围
第 3 步:发现授权服务器的端点
拿到授权服务器地址后,客户端去问它「你的各个端点在哪」。两种发现机制:
| 机制 | 标准 |
|---|---|
| OpenID Connect Discovery | OIDC Discovery 1.0 |
| OAuth 2.0 Authorization Server Metadata | RFC 8414 |
分工是不对称的,容易记反:授权服务器 MUST 至少提供其中一种;而客户端 MUST 两种都支持。 也就是说客户端不能挑食——你不知道对面用的是哪套。
以 Keycloak 为例,实际地址是 http://localhost:8080/realms/master/.well-known/openid-configuration,返回:
{
"issuer": "https://auth.your-server.com",
"authorization_endpoint": "https://auth.your-server.com/authorize",
"token_endpoint": "https://auth.your-server.com/token",
"registration_endpoint": "https://auth.your-server.com/register"
}
第 4 步:客户端注册
客户端得先在授权服务器那儿有个身份。这里是近期变化最大的一环,网上大量教程还停留在旧做法上。
规范原文:MCP 客户端 MUST 通过以下三种机制之一获得 client ID:
| 方式 | 标准 | 现状 |
|---|---|---|
| Client ID Metadata Documents(CIMD) | draft-ietf-oauth-client-id-metadata-document-00 | SHOULD 支持,新方向 |
| 预注册 | — | 一直可用 |
| 动态客户端注册(DCR) | RFC 7591 | MAY 支持,已标记废弃 |
DCR 被废弃了。 规范里的原话是:动态客户端注册「is deprecated and retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents」——它现在只是为了兼容那些还不支持 CIMD 的授权服务器而保留。
如果你现在在做技术选型,方向应该是 CIMD:客户端用一个可解析的 URL 作为 client ID,授权服务器去那个 URL 拉取客户端元数据文档。这样既不需要预先约定,也不需要在授权服务器上真的创建一条注册记录。
DCR 的请求体长这样(仍需兼容老服务器时会用到):
{
"client_name": "My MCP Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}
如果三条路都不通,客户端开发者有责任提供界面让用户手动填写客户端信息。
DCR 用起来爽,但有个安全含义要想清楚:未认证的 DCR 意味着任何人都能往你的授权服务器上注册任意客户端。这也正是「混淆代理(confused deputy)」攻击的前置条件之一——静态 client ID + 允许 DCR + 授权服务器设了同意 cookie,三者凑齐就能绕过用户同意窃取授权码。Keycloak 默认就限制匿名 DCR,需要在 Client registration → Trusted Hosts 里配置信任主机。
第 5 步:用户授权
到这一步才轮到用户出场,走的是标准的 Authorization Code + PKCE:
- 客户端打开浏览器访问
authorization_endpoint - 用户登录,并对请求的权限点「同意」
- 授权服务器带着 authorization code 重定向回客户端
- 客户端拿 code 去
token_endpoint换令牌
MCP 授权规范明确基于 OAuth 2.1 IETF DRAFT(draft-ietf-oauth-v2-1-13),而 OAuth 2.1 对授权码流程强制要求 PKCE。所以在 MCP 这儿没有「要不要上 PKCE」这个选择题。
还有一步很容易被忽略,但规范里是 MUST:客户端必须校验授权响应里的 iss 参数(RFC 9207)。
做法是:重定向用户之前,客户端必须把选定授权服务器的 issuer 值记下来,和 PKCE code verifier 存在同一条 per-request 记录里;收到授权响应后,在把 authorization code 发给任何 token 端点之前做比对。
服务器声明支持 iss | 响应里有 iss | 客户端动作 |
|---|---|---|
| 是 | 有 | 与记录的 issuer 做简单字符串比对 |
| 是 | 无 | 拒绝 |
| 否 / 未声明 | 有 | 与记录的 issuer 做简单字符串比对 |
| 否 / 未声明 | 无 | 放行 |
防的是混淆攻击(mix-up attack):客户端一生中会跟很多授权服务器打交道,其中一个若被攻击者控制,它可能诱导客户端把另一个诚实服务器签发的授权码或令牌发给自己。
换到的令牌:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "def502...",
"token_type": "Bearer",
"expires_in": 3600
}
第 6 步:带着 Token 调用
POST /mcp HTTP/1.1
Host: your-server.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
MCP Server 收到后必须验证令牌——收到令牌不等于令牌有效,更不等于这个令牌是发给你的。这就是下一节的主题。
resource 参数与 audience 校验:防的是令牌透传攻击
这一节是整篇里最该看懂的部分。
问题长什么样
假设攻击者手里有一个合法的 Access Token,但那是某个第三方 API 签发给他自己的。他能不能拿这个 Token 来打你的 MCP Server?
如果你的验证逻辑只检查「签名对不对、过期没过期」,答案是能。因为签名确实是那个授权服务器签的,也确实没过期。
规范把这类问题归为令牌透传(token passthrough)反模式,定义有两个维度,别混为一谈:
- Audience 校验失败 —— 服务器没验证令牌是不是发给自己的,于是接受了本该给别的服务的令牌。这打破了 OAuth 的基本安全边界。
- 真正的「透传」 —— 服务器不但接受了 audience 不对的令牌,还把它原封不动转发给下游服务,从而可能引发「混淆代理」问题:下游 API 会误以为这个令牌来自 MCP Server,或者误以为上游已经验过了。
规范对此的措辞非常硬:「MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.」——不是发给你的令牌,一个都不许收。
解法:把「这个令牌是给谁的」写进令牌里
依据是 RFC 8707 Resource Indicators。客户端传一个 resource 参数,授权服务器把它写进令牌的 aud(audience)声明,MCP Server 验证时比对 aud 是不是自己。
规范对客户端的要求同样是 MUST,而且有三条容易漏的细节:
resource必须同时出现在授权请求和令牌请求里——不是只在换令牌那一步传。- 它必须标识客户端打算使用该令牌的那个 MCP Server。
- 必须使用 MCP Server 的规范 URI(canonical URI)。
而且——无论授权服务器支不支持这个参数,客户端都必须发。
规范 URI 的合法性也有讲究:
| 合法 | 不合法 |
|---|---|
https://mcp.example.com/mcp | mcp.example.com(缺 scheme) |
https://mcp.example.com | https://mcp.example.com#fragment(带 fragment) |
https://mcp.example.com:8443 |
带不带尾斜杠都算合法绝对 URI,但规范建议统一用不带尾斜杠的形式以提高互操作性。这个细节看着琐碎,可 audience 比对是字符串匹配——多一个斜杠就是不匹配,实际排障时很容易栽在这儿。
解码后的令牌大致是这样:
{
"exp": 1755540817,
"iat": 1755540757,
"iss": "http://localhost:8080/realms/master",
"aud": "http://localhost:3000",
"sub": "33ed6c6b-c6e0-4928-a161-f2f69c7a03b9",
"typ": "Bearer",
"azp": "7975a5b6-8b59-4a85-9cba-8faebdab8974",
"scope": "mcp:tools"
}
四道关卡:iss(谁签的)、aud(给谁的)、exp(过期没)、scope(有啥权限)。缺一道都不行。
一个必须记住的生产要求
官方文档在这里有明确警告:aud 必须基于客户端传入的 resource 参数动态生成,不能写死成固定值。
为什么?Keycloak 里配 audience 的做法,是在 Client scopes → 目标 scope → Mappers → Audience 里加一个 mapper,把某个固定值写进 aud。问题就在这个「固定值」上——凡是携带了这个 scope 的令牌,aud 全都一样。于是 audience 校验就退化成了「这令牌是不是带了那个 scope」,而不是「这令牌是不是发给我这个资源的」,防不住同一授权服务器下其他资源的令牌串用。
测试环境这么配能跑通,生产环境务必改成按客户端传入的 resource 动态生成。
Token 验证的两条路
规范没规定你必须怎么验,常见两条路:
| 方式 | 标准 | 特点 |
|---|---|---|
| Token Introspection | RFC 7662 | 每次请求问一遍授权服务器,实时性好,能感知撤销;有网络开销 |
| 本地 JWT 校验 | 各语言 JWT 库 | 快,无网络往返;令牌被撤销后在过期前仍然有效 |
Introspection 的实现
端点(Keycloak):
{auth_base_url}/protocol/openid-connect/token/introspect
POST,Content-Type: application/x-www-form-urlencoded,body 带 token、client_id,机密客户端还要带 client_secret。
服务端校验顺序,各语言 SDK 基本一致:
- HTTP 响应码是不是
200—— 不是就拒 active是不是false—— 是就拒(Inactive token)aud有没有 —— 没有就拒(Resource indicator missing)- 遍历
aud,逐个和配置的资源 URL 比对 —— 全不匹配就拒 - 提取
client_id、scopes(按空格切scope)、expiresAt(exp)、subject(sub)
Python SDK 里对应 IntrospectionTokenVerifier(TokenVerifier),配置走 AuthSettings(issuer_url, required_scopes, resource_server_url),资源比对用 check_resource_allowed。
还有一道安全门值得注意:Python SDK 会检查 introspection 端点必须以 https://、http://localhost 或 http://127.0.0.1 开头,否则直接返回 None。防的是把 introspection 请求发到明文的第三方地址上去。
一个会让你排查到崩溃的坑
这个坑很值得单独说,因为它的表现形式极具误导性。
现象:接入 Keycloak 后,所有请求都返回 401,日志里什么有用信息都没有。
正确做法:aud 要按「可能是字符串,也可能是数组」来处理;遍历时遇到非 URL 的受众应当视为「不匹配」而继续,而不是抛异常崩掉。
这类问题的教训是通用的:验证逻辑里的宽泛 except 是排障黑洞。宁可让它炸出堆栈,也别让它变成一个语义模糊的 401。
验证失败该回什么:401 和 403 的分工
这块规范定得很清楚,但实现里经常糊在一起:
| 状态码 | 含义 | 什么时候用 |
|---|---|---|
401 Unauthorized | 需要授权,或令牌无效 | 没带令牌、令牌过期、签名不对、audience 不匹配 |
403 Forbidden | scope 不足或权限不够 | 令牌本身有效,但权限不够做这件事 |
400 Bad Request | 授权请求格式错误 | 请求本身畸形 |
令牌无效 → 401;令牌有效但权限不够 → 403。 混用会让客户端做出错误反应:401 会触发它重新走整个授权流程,而 403 应该触发的是「申请更多权限」。
后者对应的是升级授权(step-up authorization)流程。当客户端已经有令牌、但这次操作需要额外权限时,服务器应当回:
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"
客户端据此去补申请 files:write。规范还提醒:重新授权时应当把已经拿到的 scope 一起带上,否则会把之前授予的权限弄丢。
这套设计背后是最小权限思路——scopes_supported 应该只放「基本功能所需的最小集合」,其余权限靠 step-up 按需增量申请,而不是一上来就要一大把。规范专门有一节讲这个风险:如果服务器把所有 scope 都摊在 scopes_supported 里、客户端又全要了,那这个令牌一旦泄露(日志泄漏、内存抓取、本地拦截),攻击者就能横向访问数据、链式提权,而且撤销时只能整片重新授权。
Session 机制:从 Mcp-Session-Id 到彻底移除
聊完授权,得聊 session。这两件事在 MCP 里是必须分开理解、但又必须一起考虑的一对——原因在本节最后。
而且这块信息最容易过期:MCP 传输层在两年里改了三轮,网上大量教程讲的还是已经被移除的机制。
传输层的三轮演进
| 协议版本 | 传输形态 | session |
|---|---|---|
| 2024-11-05 | HTTP + SSE(双端点) | 连接即会话 |
| 2025-03-26 | Streamable HTTP 引入 | Mcp-Session-Id 头 |
| 2025-06-18 ~ 2025-11-25 | Streamable HTTP + MCP-Protocol-Version 头 | 同上 |
| 2026-07-28 | Streamable HTTP(删掉 GET 流端点) | 协议级 session 整个移除 |
先讲清楚旧机制,再讲为什么删。
旧机制:有状态 session 怎么工作
在 2025-03-26 到 2025-11-25 这几版里,session 的完整生命周期是这样的:
分配:服务器可以在初始化时分配一个 session ID,方式是在返回 InitializeResult 的那个 HTTP 响应上加一个 Mcp-Session-Id 头。
对这个 ID 有两条硬要求:
- 应当全局唯一且加密安全(安全生成的 UUID、JWT,或密码学哈希)
- 必须只包含可见 ASCII 字符(
0x21到0x7E)
第二条容易被忽略——这意味着 session ID 里不能有空格、不能有控制字符、不能直接塞非 ASCII 内容,否则会破坏 HTTP 头。
携带:一旦服务器在初始化时返回了 session ID,客户端必须在后续所有 HTTP 请求上带 Mcp-Session-Id 头。
缺失:需要 session 的服务器,对没带这个头的请求(初始化除外)应当返回 400 Bad Request。
过期与重建:服务器可以在任何时候终止 session,之后对带着这个 ID 的请求必须返回 404 Not Found。客户端收到 404 时必须发一个不带 session ID 的新 InitializeRequest,重新开一个会话。
这个 400 和 404 的分工很关键:400 是「你没带」,404 是「你带的那个已经没了」。前者是客户端实现问题,后者是正常的生命周期事件,客户端必须能自动恢复。
主动终止:客户端不再需要某个 session 时(比如用户退出应用),应当对 MCP 端点发一个带 Mcp-Session-Id 头的 HTTP DELETE。服务器可以回 405 Method Not Allowed,表示不允许客户端主动终止。
无状态模式(stateless):本来就可以不要 session
这是最容易被忽略的一点:无状态从来不是新东西,旧协议里它就是合法的。
回头看上面所有关于分配的措辞——「服务器可以(MAY)分配 session ID」。MAY 意味着不分配也完全合规。服务器不返回 Mcp-Session-Id,客户端就不带;不带,每个请求就都是独立自足的。 这就是无状态模式。
先统一一下术语。 规范和 SDK 用的词是 stateless / stateful(无状态 / 有状态),不是 sessionless——2025-06-18 规范的原话是「to support servers which want to establish stateful sessions」,SDK 的参数也叫
stateless_http。 两个词指向的层面其实不同:session 是机制(那个Mcp-Session-Id标识符),stateless 是这个机制带来的属性(服务端不跨请求保存状态)。不发 session ID,服务端就没法跨请求关联,自然就无状态了。所以官方选择直接描述属性,而不是描述机制的缺席。本文统一用「无状态 / stateless」。
线上看起来是什么样
有状态的初始化响应会多一个头:
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 1868a90c-cbcf-4f0d-a1c1-f2e3b4a5d6e7
之后客户端每个请求都得带着它:
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-cbcf-4f0d-a1c1-f2e3b4a5d6e7
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
无状态则是干干净净——响应里没有那个头,请求里也就没有:
POST /mcp HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
只剩 Authorization。身份完全由 Token 承载,请求之间不存在任何服务端关联。这也再次印证了前面那条铁律:授权本来就该只认 Token,session 从来不该参与身份判断。
怎么开
Python SDK 里就是一个参数。注意 v2 把它从构造函数挪到了 run():
# v1
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Bookshop", stateless_http=True, json_response=True)
mcp.run(transport="streamable-http")
# v2:MCPServer 只描述「是什么」,「怎么被服务」全归 run()
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
mcp.run(transport="streamable-http", port=3001,
stateless_http=True, json_response=True)
两个参数经常一起出现,但它们管的是不同的事:
stateless_http=True—— 每个请求一个全新的 transport,不做任何 session 追踪。json_response=True—— 每个 POST 直接回一个 JSON body,而不是开一条 SSE 流。
json_response 的代价要看清楚:那个 body 里只装得下一个响应,别的都装不下。 所以在这条腿上,工具中途回调客户端(ctx.elicit()、sampling)会抛 NoBackChannelError;而和这次调用绑定的通知——ctx.report_progress() 的进度、per-call 日志——会被直接丢弃。
你失去了什么
这是决策的关键,别只看到好处:
| 能力 | 有状态 | 无状态 |
|---|---|---|
服务端 → 客户端反向通道(sampling、push elicitation、roots/list) | 有 | 没有 |
流可恢复(Last-Event-ID 断线续传) | 有 | 没有 |
| 进度通知 / per-call 日志 | 有 | 配 json_response 后丢弃 |
| 会话级服务端内存 | 有 | 需外部存储 |
最后一条要展开说:如果你的工具逻辑依赖「上一次调用留下的上下文」,无状态下就得自己找地方存——Redis、数据库、或者干脆把状态编码进工具参数里,不能指望进程内存。
换来了什么
好处也很实在:
- 横向扩容不用粘性会话。任何副本都能应答任何请求,普通轮询负载均衡就够了。
- 适配 Serverless。函数计算、Lambda 这类环境本来就没有稳定的进程内存,有状态 session 根本没法用。
- 重启不掉线。进程重启不会让一批客户端集体需要重新初始化。
2026-07-28:协议级 session 被删掉了
新版规范做了两件大事:
- 移除 GET 流端点 —— 不再有那条用 HTTP GET 打开的独立 SSE 长连接
- 移除协议级 session —— 连
initialize握手本身都没了
2026 时代的客户端不再走「建连接 → 协商 → 通信」这套。每个请求都在 body 的 _meta 里自带 protocolVersion、clientInfo、clientCapabilities。唯一的发现调用是 server/discover,而它本身就是一个普通请求。
对老客户端的流量,只支持新版的服务器应当这样应对:
- 对 MCP 端点的 GET 或 DELETE → 回
405 Method Not Allowed - 请求里带
Mcp-Session-Id头 → 忽略它,既不铸造也不回显 session ID - 带
Last-Event-ID头 → 忽略,流不可恢复
新版还引入了两个便于网关工作的头:Mcp-Method(所有请求都带,值是 method)和 Mcp-Name(tools/call、resources/read、prompts/get 带,值是目标名)。这意味着网关和限流器可以只看 header 路由,不用解析 body。
顺带一个校验规则:MCP-Protocol-Version 头的值必须和 body 里 _meta.io.modelcontextprotocol/protocolVersion 一致,不一致要回 400 加 HeaderMismatch 错误(JSON-RPC 错误码 -32020)。
为什么要删?
官方给的理由一句话就说透了:没有任何东西把一个现代请求绑定到某个特定 worker 上。
有状态 session 在分布式部署里是个持续的麻烦。你得配粘性会话,得处理副本扩缩容时会话丢失,得考虑滚动发布期间的会话迁移。而 MCP 的大部分调用本质上是无状态的请求-响应——为了少数需要状态的场景,让整个协议背上会话包袱,划不来。
删掉之后,MCP Server 在部署形态上就和一个普通的 HTTP API 无差别了。
三条路径,两种「无状态」
这里有个很多人会搞错的地方,值得单独强调。
2026-07-28 的现代路径天生无状态,它跟 stateless_http 这个参数没有任何关系。
不是「设了也被忽略」,而是根本执行不到:传输层按 MCP-Protocol-Version 请求头分流,把现代请求交给现代 handler 之后就直接 return 了,而读取 stateless_http 的那行代码在 return 之后。
所以准确的说法是:stateless_http 只是 legacy 分支的旋钮。 它决定的是「如何服务 2025 时代的老客户端」,对 2026 流量完全无关。
三条路径摆在一起就清楚了:
| 客户端协议版本 | 会话 | 负载均衡器要做什么 |
|---|---|---|
| 2026-07-28 | 无,Mcp-Session-Id 永不设置 | 什么都不用做 |
| 2025-11-25 及更早(默认) | 有,存在某个 worker 的内存里 | 必须粘性会话,否则后续请求落到别的 worker 会拿到 404 Session not found |
2025-11-25 及更早 + stateless_http=True | 无 | 不用做。代价见前面「你失去了什么」那张表 |
从能力维度再看一遍,差异更立体:
| 有状态 session | 无状态(2025 时代显式开启) | 2026-07-28 天生无状态 | |
|---|---|---|---|
Mcp-Session-Id | 服务端分配,客户端必带 | 不分配 | 协议里不存在 |
| 初始化握手 | 需要 initialize | 需要 initialize | 无握手,server/discover 即普通请求 |
| 协议元数据 | 握手时协商一次 | 握手时协商一次 | 每个请求在 _meta 里自带 |
| 负载均衡 | 需要粘性会话 | 轮询即可 | 轮询即可 |
| 服务端会话状态 | 进程内存可用 | 需外部存储 | 需外部存储 |
| 服务器主动发起请求 | 支持 | 支持 | 不支持,改多轮往返 |
最后一行值得注意:2026-07-28 把所有 server-initiated request 都移除了,包括 sampling、elicitation、roots/list。取而代之的是多轮往返——工具不再「主动问客户端」,而是返回一个「需要输入」的结果,客户端作答后带着答案重试这次调用。方向反转了。
无状态部署的两个坑
以为开了无状态就能随便扩容?有两个坑会让你怀疑人生。
坑一:多副本下 request_state 解不开
多轮往返(multi-round-trip)工具会先返回一个「问题」而不是答案,两轮之间客户端持有一个服务端签发的不透明 request_state token,重试时服务端要解封它。
用什么密钥封的? 默认是构造时 os.urandom(32) 随机生成的。uvicorn --workers 4 意味着四次构造、四个进程、四把互不相同的密钥,从不落盘、从不共享、重启即失。
于是:第一轮落到 worker A,A 用自己的密钥封装;用户点了确认,重试是一个全新的 HTTP 请求,被负载均衡打到 worker B。B 解不开自己没签发过的 token,整轮直接被拒,工具函数根本不会被调用:
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
而且这条消息是冻结的——过期、被篡改、被重放、还是被兄弟 worker 签发,客户端拿到的永远是同一句话。真实原因只在服务端日志里。
典型症状:单 worker 一切正常,开到多 worker 后开始间歇性失败,失败频率正好等于负载均衡把两轮分开的概率。
解法是 RequestStateSecurity,但它有两半,只配一半照样失败:
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# 两半都要对:同一组 keys,同一个 name
mcp = MCPServer("billing",
request_state_security=RequestStateSecurity(keys=[SHARED_KEY]))
- 第一半
keys=[...]—— 所有实例共用同一个至少 32 字节的密钥。keys[0]负责签发,列表里每一把都能解封,这就是密钥轮换环。生成:python -c "import secrets; print(secrets.token_hex(32))" - 第二半是服务器的
name—— 这半几乎没人能找到。每个封装 token 都把服务器 name 作为 audience 声明带上,回来时严格校验。 同一份代码构造的实例名字自然相同,所以平时根本注意不到;可一旦你按 Pod 命名(MCPServer(f"billing-{POD}"),看起来像个良好的可观测性习惯),跨实例重试会全部被拒,共享密钥也救不了。
如果按实例命名是刚需,就显式固定 audience:RequestStateSecurity(keys=[...], audience="billing")。
最容易中招的是这一点:哪怕你从没手写过 InputRequiredResult,只要工具参数用了 Resolve(...),它就是个多轮往返工具,SDK 会替它签发 request_state——同样的默认密钥,同样的跨 worker 失败。
坑二:部署到真实域名后拒绝一切连接
既然讲到了无状态部署,这个坑不提对不起读者。
streamable_http_app() 无法知道自己将被挂在哪个域名下,所以默认取最安全的假设:localhost。不传 transport_security= 时它会自动开启 DNS-rebinding 保护,只接受 Host 为 127.0.0.1 / localhost / [::1]。
一旦部署到真实域名,这个默认值会拒绝每一个请求,而且检查发生在任何 MCP 逻辑之前:
421 Misdirected Request Invalid Host header
403 Forbidden Invalid Origin header
更难受的是 421 是纯文本 HTTP 响应,不是 JSON-RPC 错误,客户端只会抛一个笼统的 transport error,被拒的主机名只出现在服务端日志的一条 warning 里。
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"], # 两条都要写
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
注意 allowed_hosts 是精确字符串匹配,裸域名和带端口的要分别写两条。如果反向代理已经管控了 Host,诚实的做法是显式关掉:TransportSecuritySettings(enable_dns_rebinding_protection=False)。
还有个陷阱:给 run() 传 host="mcp.example.com" 不等于加白名单——它只是让 localhost 默认不再生效,结果是所有 Host / Origin 全放通,语义完全不同。
记住这条经验法则:刚部署就拒绝一切连接的服务器,在证明不是之前,都先当作 Host 白名单问题。
SDK 侧还有哪些要注意
上面已经把 stateless_http 和多副本的坑讲透了,这里补几个容易踩的版本差异。
Python SDK 已经出到 v2,同时支持 2026-07-28 和所有更早版本。最先撞到的变化是改名,而且旧路径是直接移除、没有过渡期:
| v1 | v2 |
|---|---|
mcp.server.fastmcp.FastMCP | mcp.server.MCPServer |
ctx.fastmcp | ctx.mcp_server |
FastMCPError | MCPServerError |
| 构造函数收传输参数 | 传输参数一律给 run() |
好消息是 @mcp.tool() / @mcp.resource() / @mcp.prompt() 的用法与 v1 兼容,装饰器风格的服务器基本改完导入就迁移完了。
几个隐式变化值得留意:
MCPServer(..., port=9000)会直接抛TypeError。 构造函数只描述服务器「是什么」(name、instructions、lifespan、auth),传输参数全归run()。- Streamable HTTP 的 lifespan 现在只跑一次(启动时),状态被所有 session 和请求共享。v1 里是每 session 一次,
stateless_http=True下甚至每请求一次。连接池、缓存这类资源因此便宜了很多,但 per-connection 的资源必须移进 handler 内部,否则会被意外共享。 - SDK 没有
workers=这个旋钮。mcp.run("streamable-http")永远只起一个 uvicorn 进程。要多进程就把streamable_http_app()交给uvicorn --workers 4或 gunicorn。 EventStore在 2026-07-28 上没有意义。 可恢复性是 legacy 有状态分支的特性,现代交换就是一个 POST 一个响应,没什么可恢复的。- 多副本要跨实例投递变更通知,得自己在 Redis / NATS 上实现
SubscriptionBus(一个只有publish和subscribe两个方法的 Protocol),所有副本共享同一个。SDK 自带的InMemorySubscriptionBus只跨越同进程内的多个 server 对象,是模型,不是部署方案。
最后是个好消息:向后兼容基本零成本。同一个 app、同一个 server 对象,既能应答 2025 客户端的 initialize,也能应答 2026 客户端的请求——不用配置、不用切换开关、不用分开部署。客户端侧也会自动探测:先试 server/discover,服务器较旧就回退到 initialize 握手。
Session 与授权的交汇点:一条穿越了版本更迭的铁律
前面说这两件事「必须分开理解,但又必须一起考虑」,原因就在这里。
规范里有一条安全约束,措辞极硬,而且在 session 被删掉之后依然活着——只是换了个名字。
2025-11-25 及更早版本,这条规则叫「Session Hijacking」,原文是:
MCP Servers MUST NOT use sessions for authentication. MCP servers MUST use secure, non-deterministic session IDs. MCP servers SHOULD bind session IDs to user-specific information… Use a key format like
<user_id>:<session_id>.
2026-07-28 版本,因为协议级 session 已经不存在了,这一节被整个换成了「State Handle Hijacking」,开头直接写着「MCP is stateless and has no protocol-level sessions」,规则改成:
MCP servers MUST NOT treat possession of a state handle as authentication. MCP servers SHOULD bind handles server-side to the authenticated user, for example by keying stored state as
<user_id>:<handle>where the user ID is derived from the verified token rather than supplied by the client.
看出来了吗?载体从 Mcp-Session-Id 换成了「状态句柄」,但规则一模一样:持有某个标识符 ≠ 通过了身份认证。
所谓状态句柄,就是 2026 时代那些需要跨请求保持状态的场景里,服务器自己铸造、然后作为普通工具参数收回来的东西——购物车 ID、工作流 ID 之类。攻击方式也很直白:拿到或猜到别人的句柄,就能操作别人的状态。
为什么这个错误如此常见
因为「拿标识符当凭证」是个太自然的直觉。做过传统 Web 开发的人几乎是本能反应——PHP 里 session_start() 之后,$_SESSION['user_id'] 就是身份,session ID 就是凭证,这套模型用了二十年,而且在传统 Web 里它是对的。
但在 MCP 里不成立,原因有三:
- 标识符会出现在不该出现的地方。session ID 是个 HTTP 头,会被代理记录、被网关日志打印、被 APM 采集;状态句柄更糟——它是工具参数,会进模型上下文、进对话记录。而 Access Token 有明确的处理规范(不得日志化
Authorization头)。 - 生命周期不一致。用户在授权服务器那边撤销了权限,session 却还在;反过来,session 因为服务重启掉了,授权其实还有效。
- 载体本身不稳定。2026-07-28 之后协议级 session 根本不存在了,任何建立在它之上的授权逻辑直接失效。
正确做法
授权状态只认 Token,每个请求都独立验证 Authorization 头——规范原话是「MCP servers that implement authorization MUST verify all inbound requests」,注意是 all,没有例外。
标识符(session ID 或状态句柄)只用来关联与身份无关的上下文,并且:
- 用安全随机数生成,别用可预测或递增的 ID
- 在服务端把它绑定到已认证用户,键设计成
<user_id>:<handle>,其中 user ID 必须从验证过的令牌里取,不能由客户端提供——这样即便攻击者猜中了句柄,也冒充不了别人 - 考虑加过期时间;认证状态变化时重新生成
最后一条尤其重要,它把「猜中句柄」的收益直接归零。
实现坑与安全清单
把散落在各处的要点收拢成一张表,接入前逐条过一遍。
| 类别 | 要求 |
|---|---|
| 不接受不属于自己的令牌 | MUST NOT 接受任何非显式签发给本服务器的令牌,且不得转发给下游 |
| 始终验证所有入站请求 | 规范原文是 verify all inbound requests,没有例外 |
| 不要自己实现令牌验证 | 用成熟、经充分测试的安全库,别手写 JWT 校验 |
| 短时效 access token | 长效令牌被盗后滥用窗口太大 |
| 令牌加密存储 | 服务端缓存要有访问控制和健壮的淘汰策略,避免复用过期令牌 |
| 生产强制 HTTPS | 除开发期 localhost,不得通过明文 HTTP 接受令牌或重定向回调 |
| 最小权限 scope | scopes_supported 只放基本功能最小集,其余靠 step-up 增量申请 |
| 不记录凭据 | 绝不日志化 Authorization 头、token、code、secret;清洗 query string 和 header |
| 分离两套凭证 | MCP Server 自己的 client secret 不得复用于终端用户流程;secret 进密钥管理器,不进代码库 |
| 返回正确的挑战 | 401 带 WWW-Authenticate(含 resource_metadata,建议带 scope);scope 不足用 403 + insufficient_scope |
客户端校验 iss | RFC 9207,防混淆攻击;服务器声明支持却没返回 iss 时必须拒绝 |
resource 参数不能省 | 授权请求和令牌请求都要带,且无论授权服务器支不支持都得发 |
| DCR 优先换 CIMD | DCR 已废弃;未认证的 DCR 是混淆代理攻击的前置条件之一 |
| 固定单一 issuer | 除明确多租户外应锁定单一 realm;即使同一授权服务器签名,也要拒绝其他 realm 的令牌 |
| audience 别用通用值 | 不接受 api 这类泛化 audience;必须由客户端传入的 resource 动态生成 |
| 错误信息别泄露 | 对客户端返回通用消息,详细原因 + correlation ID 记内部日志 |
| 标识符不做授权 | session ID / 状态句柄都不得当作认证;服务端按 <user_id>:<handle> 绑定,user ID 取自验证过的令牌 |
| 校验 Origin / Host | 防 DNS rebinding;本地运行只绑 127.0.0.1,不要绑 0.0.0.0 |
最后再强调一遍那个排障黑洞:验证链路上不要写宽泛的 except。所有「莫名其妙的 401」几乎都来自被吞掉的异常。
小结
回到前言里那三个问题,现在应该都有答案了:
它如何知道去哪授权? 靠 401 响应头里的 resource_metadata 找到 PRM 文档,从 PRM 找到授权服务器,再从授权服务器的元数据端点找到具体端点。三级发现,客户端只需要知道一个 MCP Server 的 URL。
如何获得 Token? Authorization Code + PKCE(OAuth 2.1 强制),并且客户端必须校验授权响应里的 iss 来防混淆攻击。拿 client ID 的方式则正在从 DCR 迁向 Client ID Metadata Documents——DCR 在 2026-07-28 规范里已标记废弃。
MCP Server 为什么相信这个 Token? 因为它验证了 iss(谁签的)、aud(是不是给我的)、exp(过期没)、scope(有没有权限)。其中 aud 是防令牌透传的关键,必须由客户端传入的 resource 参数动态生成——规范的底线是「不是发给你的令牌,一个都不许收」。
而 session 是一条正在消失的独立轨道。从 2025-03-26 的 Mcp-Session-Id,到「本来就可以选择不分配」的无状态模式,再到 2026-07-28 把协议级 session 整个删掉——方向非常明确:MCP Server 正在变成一个普通的、无状态的、可以随便横向扩容的 HTTP 服务。
这里要再强调一次那个最容易搞混的点:同样叫「无状态」,来路完全不同。 2025 时代是靠 stateless_http=True 主动放弃 session,代价是失去反向通道和可恢复性;2026 时代则是协议层面天生无状态,跟那个参数没有任何关系——不是「设了被忽略」,而是代码根本执行不到那一行。
如果你现在要新写一个远程 MCP Server,我的建议是直接按无状态设计,一份清单:
- 授权只认 Token,每个请求独立验证,绝不把身份挂在
Mcp-Session-Id上。 - 会话上下文放外部存储(Redis / 数据库),或者干脆编码进工具参数,不要指望进程内存。
- 提前配好
transport_security,别等部署到真实域名才发现 421 拒绝一切。 - 只要用了
Resolve(...)或多轮往返,就必须配RequestStateSecurity——共享 keys 和统一的服务器 name,两半缺一不可。 - 别用
json_response=True配需要进度回报的长任务,那条腿上的进度通知会被直接丢弃。
这样无论客户端说的是哪个协议版本,你都不用改架构。
参考资料
MCP 官方规范
- 授权规范(2026-07-28) · 安全最佳实践(2026-07-28) · 安全最佳实践(2025-11-25,含 Session Hijacking 一节)
- Streamable HTTP 传输(2026-07-28) · 旧版 session 机制(2025-06-18)
- 授权教程
Python SDK
标准
- OAuth 2.1 draft-ietf-oauth-v2-1-13 · OAuth Client ID Metadata Documents draft-00 · OIDC Discovery 1.0
- RFC:9728 PRM · 8414 AS Metadata · 7591 DCR(已废弃) · 8707 Resource Indicators · 7662 Introspection · 9207 Issuer Identification · 6750 Bearer Token Usage
本文所有协议行为均以 MCP 官方规范原文为准(主要是 2026-07-28,涉及旧机制处标注了 2025-06-18 / 2025-11-25),SDK 行为以 Python SDK v2 官方文档为准。 MCP 规范迭代很快——两年内传输层改了三轮、DCR 从推荐变成废弃、协议级 session 被整个移除。本文写于 2026 年 9 月,实际接入前请核对你的客户端与 SDK 所支持的协议版本,并以当时的官方规范为准。