生命不息,折腾不止。前四课把 Server 造出来、挂上网、补上反问能力(MRTR)和变更通知(subscriptions/listen);这一课啃掉最后两块硬骨头——长任务怎么异步化(Tasks 扩展),以及 Server 要面对多用户时怎么授权(OAuth 加固:RFC 9207 的 iss 校验 + DCR 到 CIMD 的迁移),最后把你手上这根链路收口成一套能交付给别人的完整服务。

上一篇结尾我把话说死了:无状态化把「会话说完了」,但代价是原来那些「搭便车」的活儿——限流、凭据、可观测——全得自己重做。今天这两块,恰好是无状态世界里最绕不开的两件:一件管**「一个活要跑很久怎么办」,一件管「谁来用我的 Server 怎么认」**。

一、为什么长任务不能赖着连接不走

先回到最朴素的问题:一个 MCP 工具调用,理想状态是「你问一句,我马上答一句」,几百毫秒搞定。但真实世界不是这样——「把 500 条数据清洗一遍」「抓三个网站生成一份报告」「让模型写一篇长文」,这些活动辄几十秒到几分钟。

老做法是让客户端「傻等」:发起 tools/call,挂起连接,等 Server 干完了把结果原样返回。功能上能跑,但两个代价特别明显:

  1. 一条长调用把连接钉死在那儿。HTTP/SSE 那种「事件流」被占住,负载均衡器盯着这条超时连接直发愁;再叠上无状态化之后「重试可能落到别的实例」,你根本没法优雅地续着等。
  2. 客户端自己也难受。关掉窗口?开新会话?这半个小时的活,进度和你断了干系,只能从头再来。

所以 2026-07-28 这版协议做了一个很干净的切割:把「长任务」从核心协议里搬出去,做成一个独立扩展 io.modelcontextprotocol/tasks(最初是核心里的 SEP-1686,后来移到扩展里,规范号 SEP-2663)。核心协议保持「无状态、短平快」,长任务走扩展:提交即返回、拿一个句柄、轮询拿进度、最后按句柄收结果——连接不霸占,状态不靠内存。

一句话记住这个扩展的定位:它解决的不是「能不能异步」,而是「异步到哪个句柄上」。

二、Tasks 扩展的报文长啥样

协议层面,Tasks 的玩法要点就三条,先看清报文,代码才有对照。

第 1 步:客户端 opt-in,Server 决定「卸后台」还是「照常答」。

客户端要在一开始就声明「我准备好了接任务」,在请求的 _meta 里带上 tasks 扩展键。Server 收到这个 opt-in,才可以决定把这个调用卸到后台,回一个 CreateTaskResult 而不是普通的 CallToolResult:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": 1,
"method": "some_method",
"params": {
"_meta": {
"modelcontextprotocol.io/task": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"keepAlive": 60000
}
}
}
}

这里有个强约束,得先记住:Server 绝不能对一个没带 opt-in 的请求回 CreateTaskResult(SDK 在服务端强制校验)。反过来也一样——你 opt-in 了,Server 也可以选择「这活很快,直接答」,回普通结果,不非得建任务。

第 2 步:轮询状态 —— tasks/get。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/get",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"_meta": {
"modelcontextprotocol.io/related-task": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
}
}
}
}
1
2
3
4
5
6
7
8
9
10
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"keepAlive": 30000,
"pollFrequency": 5000,
"status": "submitted"
}
}

注意两个字段: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
2
3
4
5
6
7
8
{
"jsonrpc": "2.0",
"id": 72,
"error": {
"code": -32602,
"message": "Cannot retrieve result: Task status is 'working', not 'completed'"
}
}

所以别手写循环检索,用 SDK 给的轮询器(下面马上讲),它自己会等到终态。

三、Python SDK 实操:提交、轮询、收结果

原理讲完,直接上能复制粘贴的代码。Python SDK(v2 稳定线)里,Tasks 目前挂在 experimental 命名空间下,跟着 draft 规范走,API 可能变。客户端三步走:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from mcp.client.session import ClientSession
from mcp.types import CallToolResult

async with ClientSession(read, write) as session:
await session.initialize()

# 1) 把工具当任务提交:立即返回,绝不阻塞
result = await session.experimental.call_tool_as_task(
"process_data",
{"input": "hello"},
ttl=60000, # 结果在服务端保留多久(毫秒)
)
task_id = result.task.taskId
print(f"任务已建:{task_id},初始状态 {result.task.status}")

# 2) 轮询,直到终态
async for status in session.experimental.poll_task(task_id):
msg = status.statusMessage or ""
print(f" 状态:{status.status} {msg}")

# 3) 取最终结果
final = await session.experimental.get_task_result(task_id, CallToolResult)
print(f"结果:{final.content[0].text}")

CallTaskResult 里最有用的两个字段:task.taskId(轮询的句柄)和 task.pollInterval(Server 建议的轮询间隔)。poll_task 会自动按这个节奏问,不会自己在那儿疯狂轮询打爆 Server。

再看一个真实长任务的样子——Server 侧用一个「异步生成日报」的工具举例。它要抓好几处数据、再调模型润色,几十秒起步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
from openai import AsyncOpenAI
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("report-bot")

# Server 这边把模型出口统一成 OpenAI 兼容形态,一个 base_url 一个 key 接多家
llm = AsyncOpenAI(
base_url="https://ai.aklibk.com/v1", # 中转站,OpenAI 兼容接口
api_key="sk-xxxx",
)

@mcp.tool()
async def generate_daily_report(topic: str) -> str:
"""抓数据 + 调模型,异步生成一份日报。"""
data = await fetch_business_data(topic) # 第一步:抓数据(慢)
summary = await summarize(data) # 第二步:本地汇总
resp = await llm.chat.completions.create( # 第三步:调模型润色
model="deepseek-chat",
messages=[{"role": "user", "content": summary}],
)
return resp.choices[0].message.content

关键体会在最后一步: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
2
3
4
5
6
7
8
# 发起授权前,从授权服务器元数据里读到 issuer 并记下来
expected_issuer = as_metadata["issuer"] # 例如 https://auth.example.com

# 用户点完登录,回跳 URL 里取参数
iss = callback_params.get("iss")
if expected_issuer and iss is not None and iss != expected_issuer:
# issuer 对不上:可能撞上了 mix-up 攻击,作废,别去换 token
raise OAuthError("issuer mismatch — refuse to exchange the code")

这一刀之所以值得单独拎出来,是因为它成本几乎为零(就一次字符串比对),但挡住的是「授权被劫走」这种要命的事。老教程大多不提,2026-07-28 之后它变成规范的 MUST,写的时候建议照做。

五、OAuth 加固第二刀:DCR 到 CIMD 的迁移

第二刀是「客户端注册」的方式迭代,属于「老代码还跑得动,但新代码应该换写法」的那类。

背景:MCP 的客户端和授权服务器通常是陌生人——用户今天在这个客户端里贴个 Server 地址,明天在另一个客户端里贴,没有任何「预先白名单」。所以客户端得现场把自己「介绍」给授权服务器,拿一个 client_id 再走流程。历史上有两条路:

  1. DCR(Dynamic Client Registration,RFC 7591):客户端到授权服务器现场 POST 自己的元数据,换回一个 client_id + client_secret。功能全,但有个烦人的尾巴——每次都要一次运行时注册往返,而且会沉淀一堆没人管的孤儿客户端。
  2. CIMD(Client ID Metadata Documents):换了个思路——client_id 直接就是一个 HTTPS URL,指向客户端自己公网上的 metadata 文档。客户端不用注册,授权服务器要啥就去这个 URL 现拉。

2026-07-28 这版规范把 DCR 标记为 deprecated,把 CIMD 提为推荐。你不用急着把线上 DCR 拆掉——它还保留着用来兼容不支持 CIMD 的授权服务器——但新代码里,client_id 的写法变了:

1
2
3
4
5
6
7
8
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}

看到没?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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pydantic import AnyHttpUrl
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP


class SimpleTokenVerifier(TokenVerifier):
"""验令牌:这里接你的真实校验(解码 JWT / 回调授权服务器 introspect)。"""
async def verify_token(self, token: str) -> AccessToken | None:
# 校验签名、过期时间、aud(令牌是不是发给这个 Server 的)……
# 验不过返回 None,客户端会收到 401
...


mcp = FastMCP(
"Weather Service",
json_response=True,
token_verifier=SimpleTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"), # 授权服务器地址
resource_server_url=AnyHttpUrl("http://localhost:3001"), # 本 Server 地址
required_scopes=["user"], # 需要的 scope
),
)

@mcp.tool()
async def get_weather(city: str = "London") -> dict[str, str]:
return {"city": city, "temperature": "22", "condition": "Partly cloudy"}

if __name__ == "__main__":
mcp.run(transport="streamable-http")

配好之后,Server 就发布一个 discovery 文档(RFC 9728 的 Protected Resource Metadata),客户端照着找授权服务器:

1
curl http://localhost:3001/.well-known/oauth-protected-resource
1
2
3
4
{
"resource": "http://localhost:3001",
"authorization_servers": ["https://auth.example.com"]
}

客户端那一侧,SDK 把「发现授权服务器 → 记 PKCE → 跳浏览器登录 → 收 code → 校验 issuer → 换 token → 顺手续 refresh」这一整串打包成了一个 OAuthClientProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientMetadata

# storage 里存 token 和 client_info,别每次都重新登录
oauth_auth = OAuthClientProvider(
server_url="http://localhost:3001",
client_metadata=OAuthClientMetadata(
client_name="Example MCP Client",
redirect_uris=["http://127.0.0.1:3000/callback"],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope="user",
),
storage=MyTokenStorage(),
redirect_handler=handle_redirect, # 打印授权 URL(或弹浏览器)
callback_handler=handle_callback, # 收用户贴回的回跳 URL,解析 code/state
)

async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"可用工具:{[t.name for t in tools.tools]}")

整套流程走下来,宏观上就是标准 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 第四课

最后照例一张排障清单,今天这两块的坑都在这儿:

  1. 任务停在 working 就去取结果,报 -32602:这正常,等终态(completed/failed/cancelled)再取,或直接交给 poll_task 等;
  2. Server 回了 CreateTaskResult 却被客户端报错:极可能是客户端没带 tasks 扩展的 opt-in。Server 不能对没 opt-in 的请求回任务结果;
  3. 老客户端连上 tasks 版 Server 直接挂:tasks 需要 2026-07-28+,老客户端不认识。灰度期让老客户端走同步路径;
  4. OAuth 一直登录失败、报 issuer 不匹配:看授权服务器元数据里 authorization_response_iss_parameter_supported 是不是 true,以及回跳里到底带没带 iss。声明了却没带,就该拒,别手滑「不校验放行」;
  5. 拿回 token 但 Server 还是 401:十有八九是 aud 没对上——token 不是签给你这个 Server 的;
  6. 想复用「用户的 token」去调上游 API:别这么干,规范禁止 token 透传,Server 用自己的凭据调;
  7. 本地 stdio 的 Server 硬套 OAuth:方向错了,stdio 读环境变量即可。

一句话收口:长任务交给 Tasks 扩展「交出句柄而非霸占连接」,授权交给 OAuth 两把锁「iss 防混淆、CIMD 少还债」,无状态 Server 才算真正对陌生人开得了门、扛得住重活。 到这儿,你手上这套 MCP 的东西,已经能拆成文档、发给别人接了。

生命不息,折腾不止。MCP 实战五课到此收官。下一篇开个新坑「AI Agent 框架对比」系列第一课:把 LangGraph、Microsoft Agent Framework(MAF)、Claude Code 这三套摆到台面上,比架构、比上手成本、比各自适合什么场景,帮你在「自建 vs 现成」之间拿个准主意。咱们下篇见。