MCP 实战第五课:长任务丢后台跑 + OAuth 锁住授权
生命不息,折腾不止。前四课把 Server 造出来、挂上网、补上反问能力(MRTR)和变更通知(subscriptions/listen);这一课啃掉最后两块硬骨头——长任务怎么异步化(Tasks 扩展),以及 Server 要面对多用户时怎么授权(OAuth 加固:RFC 9207 的
iss校验 + DCR 到 CIMD 的迁移),最后把你手上这根链路收口成一套能交付给别人的完整服务。
上一篇结尾我把话说死了:无状态化把「会话说完了」,但代价是原来那些「搭便车」的活儿——限流、凭据、可观测——全得自己重做。今天这两块,恰好是无状态世界里最绕不开的两件:一件管**「一个活要跑很久怎么办」,一件管「谁来用我的 Server 怎么认」**。
一、为什么长任务不能赖着连接不走
先回到最朴素的问题:一个 MCP 工具调用,理想状态是「你问一句,我马上答一句」,几百毫秒搞定。但真实世界不是这样——「把 500 条数据清洗一遍」「抓三个网站生成一份报告」「让模型写一篇长文」,这些活动辄几十秒到几分钟。
老做法是让客户端「傻等」:发起 tools/call,挂起连接,等 Server 干完了把结果原样返回。功能上能跑,但两个代价特别明显:
- 一条长调用把连接钉死在那儿。HTTP/SSE 那种「事件流」被占住,负载均衡器盯着这条超时连接直发愁;再叠上无状态化之后「重试可能落到别的实例」,你根本没法优雅地续着等。
- 客户端自己也难受。关掉窗口?开新会话?这半个小时的活,进度和你断了干系,只能从头再来。
所以 2026-07-28 这版协议做了一个很干净的切割:把「长任务」从核心协议里搬出去,做成一个独立扩展 io.modelcontextprotocol/tasks(最初是核心里的 SEP-1686,后来移到扩展里,规范号 SEP-2663)。核心协议保持「无状态、短平快」,长任务走扩展:提交即返回、拿一个句柄、轮询拿进度、最后按句柄收结果——连接不霸占,状态不靠内存。
一句话记住这个扩展的定位:它解决的不是「能不能异步」,而是「异步到哪个句柄上」。
二、Tasks 扩展的报文长啥样
协议层面,Tasks 的玩法要点就三条,先看清报文,代码才有对照。
第 1 步:客户端 opt-in,Server 决定「卸后台」还是「照常答」。
客户端要在一开始就声明「我准备好了接任务」,在请求的 _meta 里带上 tasks 扩展键。Server 收到这个 opt-in,才可以决定把这个调用卸到后台,回一个 CreateTaskResult 而不是普通的 CallToolResult:
1 | { |
这里有个强约束,得先记住:Server 绝不能对一个没带 opt-in 的请求回 CreateTaskResult(SDK 在服务端强制校验)。反过来也一样——你 opt-in 了,Server 也可以选择「这活很快,直接答」,回普通结果,不非得建任务。
第 2 步:轮询状态 —— tasks/get。
1 | { |
1 | { |
注意两个字段:status(当前状态)和 pollFrequency(Server 建议你多久来问一次,单位毫秒)。状态机是一条单行道:
submitted → working → completed / failed / cancelled
中间还会插一个你已经脸熟的 input_required——上一课讲的 MRTR,在长任务里也照用:任务跑到一半要问你要输入,就停在 input_required,等你补完再往下走。
这里我得先更正一个特别容易记混的点:Tasks 最早是 2025-11-25 那版里的实验性核心特性(规范号 SEP-1686),当时的报文里还有 tasks/result、tasks/delete 这些方法。2026-07-28 把它正式搬进扩展(SEP-2663)之后,方法收敛成三个,客户端就靠这仨驱动任务:
tasks/get:轮询状态;tasks/update:中途输入——任务跑到input_required时,把你补的答案(elicitation / sampling)送回去;tasks/cancel:主动取消。
而 tasks/list 被直接移除了。原因很实在:没有 session 之后,「列出所有任务」没法安全地按调用方隔离,谁都能翻到别人的任务清单。至于「任务干完、拿结果」这件事,现在落在客户端身上就是轮询到 completed,再拿回那个 CallToolResult——C# SDK 的 CallToolWithPollingAsync 干的就是这个:按 Server 建议的节奏轮询 tasks/get,到终态返回最终结果,中途收到的输入请求派给已注册的 handler、并对已经答过的输入键去重。
所以别去背具体方法名的旧清单,记住主线就够了:客户端先 advertise 扩展(opt-in),Server 决定这个调用要不要卸后台;卸了就给一个句柄,客户端 tasks/get 轮询、必要时 tasks/update 补输入、不想等就 tasks/cancel。具体到某个 SDK(Python 的 experimental 命名空间、C# 的 ModelContextProtocol.Extensions.Tasks 包),字段和方法名略有出入,以你 SDK 的 experimental 文档为准。
有个坑提一句:任务还停在 working 时就急着取结果,会报协议错误:
1 | { |
所以别手写循环检索,用 SDK 给的轮询器(下面马上讲),它自己会等到终态。
三、Python SDK 实操:提交、轮询、收结果
原理讲完,直接上能复制粘贴的代码。Python SDK(v2 稳定线)里,Tasks 目前挂在 experimental 命名空间下,跟着 draft 规范走,API 可能变。客户端三步走:
1 | from mcp.client.session import ClientSession |
CallTaskResult 里最有用的两个字段:task.taskId(轮询的句柄)和 task.pollInterval(Server 建议的轮询间隔)。poll_task 会自动按这个节奏问,不会自己在那儿疯狂轮询打爆 Server。
再看一个真实长任务的样子——Server 侧用一个「异步生成日报」的工具举例。它要抓好几处数据、再调模型润色,几十秒起步:
1 | import asyncio |
关键体会在最后一步:Server 本身需要模型能力时,直接调模型提供商的 API 就够了(上一课讲过,sampling 废弃后这本来就是正路)。出口设计成「一个 base_url + 一个 key」的 OpenAI 兼容形态,国产模型接 DeepSeek、通义、Kimi 按量付费、价格便宜,切换模型只改一个 model 名,Server 代码一行不用动。
重要的适配性判断:Tasks 走的是「当前 draft 扩展」状态,具体到某个 SDK(Python 的 experimental、C# 的 ModelContextProtocol.Extensions.Tasks 包)都需要 2026-07-28 及以后的协议版本。更老的客户端根本不认识这个扩展键,所以跟上一课的灰度思路一样——新客户端走 tasks,老客户端照旧同步等,别一刀切。
四、OAuth 加固第一刀:RFC 9207 的 iss 校验
长任务解决了「干多久」,下一个问题是「谁来干」。无状态化之后 Server 彻底变成「谁拿有效令牌我就服务谁」,认证这条路必须自己认真铺。
MCP 的授权不是自己发明的协议,而是挑了 OAuth 2.1 的一个子集。你真正要记住的角色关系就一句:你的 MCP Server 是一个 OAuth「资源服务器」(Resource Server),它自己不登录用户,而是去验一个独立「授权服务器」(Authorization Server)签发的令牌。 所以「给 Server 上锁」=「让 Server 认得有效令牌」,登录那摊子事交给 Keycloak、Auth0、或者任何兼容的授权服务器都行。
今天第一刀,补一个绝大多数人闭着眼睛就跳过的安全洞:iss(issuer)校验。
先看它防的到底是什么——mix-up(混淆)攻击。场景是这样:用户想连一个正常的 MCP Server,结果有个恶意 Server 在授权流程里插一脚,把用户导向一个假的授权服务器,用户登录了、授权了、拿回一个 code,最后这个 code 被发回到了恶意 Server 的 token 端点,等于把授权白白交了出去。
防这个办法很简单:客户端在发起授权前,先把「我到底在跟哪个授权服务器说话」记下来(issuer 值);授权服务器回跳时,如果带了 iss 参数,客户端必须拿它和记下的 issuer 做字符串比对,对不上就当场作废这次授权,连那个 code 都别往 token 端点发。 这就是 RFC 9207 干的事。
映射到规范里的细则,一个表就能背下来:
authorization_response_iss_parameter_supported |
回跳里有没有 iss |
客户端行为 |
|---|---|---|
true |
有 | 和记下的 issuer 做简单字符串比对(RFC 3986 §6.2.1),对不上就拒 |
true |
没有 | 直接拒收这次回跳 |
false 或未声明 |
有 | 依然要拿 iss 跟记下的 issuer 比对 |
false 或未声明 |
没有 | 放行 |
第二个格子是重点:授权服务器一旦声明了支持 iss(在元数据里写 authorization_response_iss_parameter_supported: true),回跳时却漏了 iss,客户端必须拒绝——因为这时候「没带 iss」本身就是异常信号,很可能被中间人抠掉了。
落到代码上,你的客户端(或宿主)在发起跳转时记一个 issuer:
1 | # 发起授权前,从授权服务器元数据里读到 issuer 并记下来 |
这一刀之所以值得单独拎出来,是因为它成本几乎为零(就一次字符串比对),但挡住的是「授权被劫走」这种要命的事。老教程大多不提,2026-07-28 之后它变成规范的 MUST,写的时候建议照做。
五、OAuth 加固第二刀:DCR 到 CIMD 的迁移
第二刀是「客户端注册」的方式迭代,属于「老代码还跑得动,但新代码应该换写法」的那类。
背景:MCP 的客户端和授权服务器通常是陌生人——用户今天在这个客户端里贴个 Server 地址,明天在另一个客户端里贴,没有任何「预先白名单」。所以客户端得现场把自己「介绍」给授权服务器,拿一个 client_id 再走流程。历史上有两条路:
- DCR(Dynamic Client Registration,RFC 7591):客户端到授权服务器现场 POST 自己的元数据,换回一个
client_id+client_secret。功能全,但有个烦人的尾巴——每次都要一次运行时注册往返,而且会沉淀一堆没人管的孤儿客户端。 - CIMD(Client ID Metadata Documents):换了个思路——
client_id直接就是一个 HTTPS URL,指向客户端自己公网上的 metadata 文档。客户端不用注册,授权服务器要啥就去这个 URL 现拉。
2026-07-28 这版规范把 DCR 标记为 deprecated,把 CIMD 提为推荐。你不用急着把线上 DCR 拆掉——它还保留着用来兼容不支持 CIMD 的授权服务器——但新代码里,client_id 的写法变了:
1 | { |
看到没?client_id 用一个 HTTPS URL 替掉了「注册换来的随机串」。授权服务器拿到这个 URL,拉下来就是一段描述这个客户端的 JSON。三选一的优先级顺序记一下:先看有没有预注册的凭据(企业场景常见,Claude/Cursor/VS Code 都允许你手动填),其次用 CIMD,最后才回退到 DCR。
这刀的实际意义是运维上的:少一个运行时注册往返,少一份「客户端清单得自己维护」的债。尤其你要是拿它做多租户、给别人派客户端,CIMD 的「一条 URL 自描述」要清爽得多。
六、给 Server 挂鉴权:TokenVerifier + AuthSettings
理论讲完,落到 Python SDK 上是这样:Server 端要做两件事——验令牌(实现 TokenVerifier)和告诉客户端去哪儿找授权服务器(配 AuthSettings,这就是 RFC 9728 的 discovery)。
1 | from pydantic import AnyHttpUrl |
配好之后,Server 就发布一个 discovery 文档(RFC 9728 的 Protected Resource Metadata),客户端照着找授权服务器:
1 | curl http://localhost:3001/.well-known/oauth-protected-resource |
1 | { |
客户端那一侧,SDK 把「发现授权服务器 → 记 PKCE → 跳浏览器登录 → 收 code → 校验 issuer → 换 token → 顺手续 refresh」这一整串打包成了一个 OAuthClientProvider:
1 | from mcp import ClientSession |
整套流程走下来,宏观上就是标准 OAuth 的那几拍:401 挑战 → 读受保护资源元数据 → 读授权服务器元数据 → PKCE + 跳登录 → 校验 iss/state → 换 token → 之后每个请求带 Authorization: Bearer <token>。区别只在「客户端和授权服务器本来是陌生人」这个设计点上,所以 PKCE(S256)和 iss 校验才是两条不能省的底线。
两个最容易翻车的点,提前给你排掉:
- 令牌是签给这个 Server 的。Server 验 token 时必须验
aud(受众)对得上自己,绝不能拿「发给别的服务的令牌」来做「令牌透传」(token passthrough)——规范明令禁止。Server 要调上游 API,得用自己的凭据另起一套。 - stdio 别套 OAuth。标准里写得很清楚:OAuth 是给 HTTP 传输用的;本地 stdio 的 Server 应该从环境变量读凭据,别把 OAuth 硬套上去。
七、收尾:把五课串成一套能交付的东西 + 排障清单
写到这,MCP 实战这条线的前四课 + 今天这一课,拼在一起应该是一台能对陌生人开放、能扛长任务、还守得住授权的完整 Server。我给你把零件按「上线清单」串一遍:
| 层 | 干什么 | 哪一课 |
|---|---|---|
| 协议底座 | 无状态 HTTP+SSE,网关头按 Mcp-Method/Mcp-Name 路由 |
第三课 |
| 反问 | Server 要输入用 MRTR(InputRequiredResult/requestState) |
第四课 |
| 通知 | 变更走 subscriptions/listen,先订阅再快照、无重放 |
第四课 |
| 长任务 | 异步交给 Tasks 扩展,提交/轮询/收结果 | 本课 |
| 授权 | OAuth 2.1:RFC 9728 发现 + PKCE + iss 校验 + CIMD |
本课 |
| 限流 | 计数器外置(无状态后别放实例内存) | 第四课 |
| 幂等 | 副作用调用带幂等键,或工具天然幂等 | 第四课 |
| 灰度 | 双端点并行,观测旧客户端占比再摘 legacy | 第四课 |
最后照例一张排障清单,今天这两块的坑都在这儿:
- 任务停在
working就去取结果,报-32602:这正常,等终态(completed/failed/cancelled)再取,或直接交给poll_task等; - Server 回了
CreateTaskResult却被客户端报错:极可能是客户端没带 tasks 扩展的 opt-in。Server 不能对没 opt-in 的请求回任务结果; - 老客户端连上 tasks 版 Server 直接挂:tasks 需要
2026-07-28+,老客户端不认识。灰度期让老客户端走同步路径; - OAuth 一直登录失败、报 issuer 不匹配:看授权服务器元数据里
authorization_response_iss_parameter_supported是不是true,以及回跳里到底带没带iss。声明了却没带,就该拒,别手滑「不校验放行」; - 拿回 token 但 Server 还是 401:十有八九是
aud没对上——token 不是签给你这个 Server 的; - 想复用「用户的 token」去调上游 API:别这么干,规范禁止 token 透传,Server 用自己的凭据调;
- 本地 stdio 的 Server 硬套 OAuth:方向错了,stdio 读环境变量即可。
一句话收口:长任务交给 Tasks 扩展「交出句柄而非霸占连接」,授权交给 OAuth 两把锁「iss 防混淆、CIMD 少还债」,无状态 Server 才算真正对陌生人开得了门、扛得住重活。 到这儿,你手上这套 MCP 的东西,已经能拆成文档、发给别人接了。
生命不息,折腾不止。MCP 实战五课到此收官。下一篇开个新坑「AI Agent 框架对比」系列第一课:把 LangGraph、Microsoft Agent Framework(MAF)、Claude Code 这三套摆到台面上,比架构、比上手成本、比各自适合什么场景,帮你在「自建 vs 现成」之间拿个准主意。咱们下篇见。