Skip to content

深入 MCP:MCP 怎么处理长时间运行的请求和持续通知?

很多 MCP Request 都可以很快完成。

例如:

text
tools/list
resources/read

可能几百毫秒就能返回。

但如果 Tool 需要扫描整个仓库、分析几千个文件,或者等待某个外部系统执行完成,一条 Request 可能持续几十秒甚至更久。

这时候问题就不再只是:

text
Request 发出去以后什么时候收到 Response?

而会变成:

text
Client 怎么知道 Server 还在工作?
多久没有完成应该认为超时?
用户点击停止以后,怎样取消当前操作?
如果 Server 需要在很久以后主动告诉 Client “资源发生变化了”,还应该继续借用这条 Request 吗?

MCP 分别用 Progress、Cancellation 和 Subscription 解决这些问题,但它们其实都围绕同一个核心展开:

text
一段通信到底应该活多久,以及结束时应该具有什么语义。

Progress、Timeout 和 Cancellation 为什么必须围绕同一条 Request 来设计?

一条普通 JSON-RPC Request 最简单的生命周期只有:

text
Request

Response

Client 发出 Request,Server 执行,最后返回 Response。

如果 Server 需要执行一分钟,那么这一分钟里 Request 依然处于正在执行中状态。

这时候 Client 最少需要区分三件事:

text
Server 正常执行,只是还没完成

Server 已经卡住,不会再完成

Client 已经不想继续等待

这三种状态分别对应:

Progress

告诉 Client:

text
当前请求还在执行,而且已经进行到某个阶段。

Timeout

表示:

text
Client 已经等待超过允许的时间,不准备继续等待。

Cancellation

则表示:

text
这条 Request 还没有正常完成,但发起方主动要求停止。

如果一个长时间 Request 完全没有任何中间信号,Client 实际上无法区分:

text
Server 正在认真处理第 999 个文件

和:

text
Server 代码死循环了

Progress 给了 Client 一个重要的 Liveness Signal(存活信号)。

但它也不是 Heartbeat(心跳)协议。

Server 不需要固定每五秒报告一次,也不保证一定发送 Progress。

当前规范只允许 Client表达:

text
如果你有进度,可以通过这个 Token 告诉我。

至于 Server 是否发送、多久发送一次,仍然由 Server 决定。

text
Request

   ├── Progress:请求还在继续

   ├── Timeout:我已经不愿意继续等

   ├── Cancellation:请停止当前工作

   └── Response:请求正常结束

这几种信号都不能脱离原始 Request 单独理解。

为什么 Progress 要单独使用 progressToken,而不是直接使用 Request ID?

如果 Client 希望接收某条 Request 的执行进度,它会在 Request Metadata 中加入:

text
progressToken

例如:

json
{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "tools/call",
  "params": {
    "name": "analyze_repository",
    "arguments": {},
    "_meta": {
      "progressToken": "repo-analysis-17"
    }
  }
}

Server 后面就可以发送:

json
{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "repo-analysis-17",
    "progress": 42,
    "total": 100,
    "message": "正在分析 Java 文件"
  }
}

已经有 id = 17 了,为什么还要另外设计一个 progressToken

因为两者承担的协议职责不同。

JSON-RPC Request ID 是:

text
Request 和最终 Response 之间的关联标识。

无论 Client 是否需要 Progress,这个 ID 都存在。

而:

text
progressToken

首先表达的是:

text
Client 希望当前 Request 可以发送 Progress。

也就是说,它同时承担了一层 Opt-in(主动启用)语义。

没有:

text
progressToken

Server 就不应该凭空给某条 Request 推送 Progress Notification。

这样 Progress 能力不会自动污染所有 Request。

另外,Progress Token 只需要在当前所有 Active Request 中保持唯一

它并不是一个长期业务 ID,也不应该被拿去充当:

text
Run ID
Task ID
Trace ID

之类的东西。

Progress 本身的数值也不能简单理解成百分比。

例如:

json
{
  "progress": 3,
  "total": 10
}

可以理解为:

text
当前完成了 3 / 10。

但如果:

json
{
  "progress": 3
}

没有 total,只能知道当前 Progress 已经推进到 3。

并不知道总共是不是 10。

规范只要求:

text
每一次 Progress 的 progress 值都必须比上一条增加。

Server 甚至可以:

text
1
2
3
4

只配合:

text
正在扫描文件
正在解析依赖
正在生成结果

这样的文本信息。

真正重要的是:Client 能看到工作还在推进。

但即使 Progress 已经达到:

text
100 / 100

也不能认为 Request 已经完成。

真正结束 Request 的仍然只能是:

最终 Response。

Progress 只是过程信息。

为什么收到 Progress 以后也不能无限延长 Timeout?

既然 Progress 能说明Server还在工作。

因此每收到一次 Progress,就重新计算 Timeout。

例如 Client 设置:

text
timeout = 30s

Server 每隔 10 秒返回一次 Progress。

那么 Client 就可以认为:

text
Server 没有卡死。

继续等待。

官方 TypeScript SDK 里也存在:

text
resetTimeoutOnProgress

这样的处理机制。

但如果设计只做到这里,就会出现另一个问题。

假设 Server 每隔 20 秒都发送一次:

text
progress = progress + 1

却永远不给最终结果。

如果每一次 Progress 都把 30 秒 Timeout 重新开始计算,那么这条 Request 理论上可以永远存在。

所以生产环境通常还需要另外一层:

Maximum Total Timeout(最大总执行时间)

例如:

text
普通 Timeout:30 秒
最大总执行时间:10 分钟

含义就是:

只要 Server 一直在正常报告 Progress,可以容忍单次执行时间超过 30 秒。

但是:

text
整个 Request 最长只能运行 10 分钟。
text
Request 开始

    ├── 20s → Progress
    ├── 40s → Progress
    ├── 60s → Progress
    ├── ...

    └── 10min → 即使仍有 Progress,也必须结束等待

这和 MRTR 中的:

text
timeout
maxTotalTimeout

其实是一类工程思想。

前者控制:

text
当前这一段通信多久没有有效进展算异常。

后者控制:

text
整个业务流程最长允许持续多久。

这两者并不一样。

另外,Progress 本身也需要 Rate Limit(频率限制)。

如果 Server 每毫秒发送一条:

text
notifications/progress

不仅不会让 Client 更准确地了解进度,反而可能造成:

网络开销、UI 刷新压力、日志污染,甚至形成新的 DoS 面。

所以 Progress 的设计目标不是越多越好。

而是在长时间没有最终 Response 时,提供足够的信息证明请求仍然在推进。

为什么 stdio 和 Streamable HTTP 的取消方式完全不同?

Cancellation 表面上很简单:

text
Client 不想继续等了,让 Server 停止。

但实际上,不同 Transport 取消信号并不一样。

stdio 为什么需要 notifications/cancelled

stdio 中,Client 和 Server 通常共享:

text
stdin
stdout

这一对 Pipe(管道)。

同一时刻可能存在多条并发 Request:

text
Request 101
Request 102
Request 103

所有消息都通过同一条字节流传输。

所以如果 Client 只想取消:

text
Request 102

显然不能直接:

text
关闭 stdout。

因为这样会把整个 MCP Connection 一起关掉,另外两条 Request 也会全部受到影响。

因此 stdio 需要显式发送:

text
notifications/cancelled

例如:

json
{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": 102,
    "reason": "用户停止了操作"
  }
}

Server 收到以后,根据:

text
requestId = 102

找到对应的执行任务,并尝试停止它。

这里的关键词是:

text
尝试。

Server 收到 notifications/cancelled 后,应该尽快停止对应 Request 的后续工作,但 MCP 不保证这个 Request 一定能被立即、完整、无副作用地终止。

为什么 HTTP 不需要再发送取消 Notification?

Streamable HTTP 的并发模型不同。

每一条 Request 自己就有独立的 HTTP Request / Response 生命周期。

如果 Server 当前通过这条 Response Stream 返回 SSE:

text
POST /mcp

SSE Response

那么 Client 想取消这条 Request 时,直接关闭当前 Response Stream 即可。

这个 Disconnect 本身就是当前 Request 被取消。

所以当前规范规定:

在 Streamable HTTP 中,Client 关闭当前 SSE Response Stream 时,Server 必须把它视为对当前 Request 的取消。

这时候再额外发送:

text
notifications/cancelled

反而没有必要。

两种 Transport 的差异可以概括成:

text
stdio
多 Request 共用一个 Channel

必须显式指定 requestId 取消

Streamable HTTP
每个 Request 有独立 Response Stream

关闭当前 Stream 就已经知道取消的是谁

这说明:

text
协议语义可以一致,但具体控制信号往往取决于 Transport 怎样表示并发。

Cancellation 为什么一定存在 Race Condition?

假设用户点击停止。

Client 发送 Cancellation。

但 Cancellation 到达 Server 之前,Server 刚好已经完成:

text
delete_file

或者:

text
deploy_production

这时候会出现:

text
Client              Server

Request  ───────────→
                  执行完成
Cancel   ───────────→
Response ←───────────

取消和完成在网络上发生了竞争。

所以:

text
“我发送了取消”不等于“操作一定没有发生”。

这就是 Cancellation Race(取消竞态)。

规范因此允许 Server:

  • 请求已经完成时忽略 Cancellation;
  • 当前操作无法取消时忽略;
  • 已经不知道这个 Request 时忽略。

Client 也应该忽略取消以后晚到的 Response。

这对于有副作用的 Tool 特别重要。

比如:

text
charge_credit_card

用户点击停止以后,不能因为 UI 显示已取消,就推断钱一定没有扣。

业务层如果需要真正的强一致 Cancel / Rollback(取消或回滚),必须由具体 Tool 自己实现。

MCP Cancellation 只解决:

text
这条正在运行的协议请求应该尽快停止。

它不替业务系统提供分布式事务。

既然新版取消了 GET SSE,为什么又需要 subscriptions/listen

前面的 Transport 文章已经讲过,现代 MCP 不再依赖旧版长期 GET SSE。

普通 Streamable HTTP Request 的 SSE 是:

Request-scoped(只属于当前请求)

例如:

text
tools/call

HTTP POST

SSE Response

Progress

最终 CallToolResult

Stream 关闭

这条 Stream 的生命周期和tools/call绑定。

当 Request 完成以后,Stream 就应该结束。

但还有另外一种事件:

text
Tool List 发生变化

Resource 在半小时以后被修改

Prompt List 发生变化

它们和某一次正在执行的:

text
tools/call

完全没有关系。

如果为了等这些事件而一直挂着某条普通 Request,就重新回到了:

text
拿业务 Request 当长期 Server Push 通道。

这正是新版想避免的。

所以 MCP 单独提供:

text
subscriptions/listen

它的语义非常明确:

text
Client 主动建立一条长期 Notification Stream。

例如 Client 可以声明:

json
{
  "method": "subscriptions/listen",
  "params": {
    "notifications": {
      "toolsListChanged": true,
      "resourcesListChanged": true,
      "resourceSubscriptions": [
        "file:///project/config.json"
      ]
    }
  }
}

也就是说:

text
我只想听这些事件。

Server 不能因为已经建立了一条长期 Stream,就开始随意推送其他消息。

这种 Filter(过滤器)设计非常重要。

因为一条长期连接如果没有明确订阅边界,很容易重新演变成:

text
Server 想推什么就推什么。

而现代 MCP 仍然坚持:

text
Client 决定自己愿意接收哪些长期通知。

所以:

text
普通 Request SSE

和:

text
subscriptions/listen

虽然都可能表现成一条长期 Stream,但语义完全不同。

普通 Request:

text
我正在等待某项操作完成。

Subscription:

text
我现在主动监听未来可能发生的某类事件。
text
Request-scoped SSE
生命周期 = Request

subscriptions/listen
生命周期 = Subscription

这也是为什么旧的:

text
resources/subscribe

以及 HTTP GET Stream 被统一重新设计。

长期通知不再挂在某个 Transport 机制上,而成为 MCP 自己明确的一种协议行为。

为什么 Subscription 还需要 Acknowledgment 和独立的 subscriptionId

Client 发出:

text
subscriptions/listen

以后,Server 并不能直接马上发送 Tool List Changed Notification。

规范要求第一条 Subscription 消息必须是:

text
notifications/subscriptions/acknowledged

例如 Client 请求:

text
toolsListChanged = true
resourcesListChanged = true
resourceSubscriptions = [A, B, C]

但 Server 可能只支持:

text
toolsListChanged
resourceSubscriptions = [A, B]

Acknowledgment(确认通知)就是告诉 Client:

text
你要求的 Subscription 我真正接受了哪些部分。

所以:

text
Request Filter

只是 Client 希望建立什么 Subscription。

Acknowledgment 才是 Server 最终同意的 Subscription。

这实际上形成了一次非常轻量的 Negotiation(协商)。

text
Client 希望监听
A + B + C

Server 实际支持
A + B

Acknowledgment
A + B

Server 在发送这条 Acknowledgment 之前,不能先发送属于该 Subscription 的普通 Notification。

否则就可能出现资源更新通知已经到了,但 Client 还不知道这条订阅到底有没有建立成功的问题。

subscriptionId 为什么直接复用 Request ID?

每一条 Subscription 还会有:

text
io.modelcontextprotocol/subscriptionId

当前协议直接使用最初:

text
subscriptions/listen

Request 的 JSON-RPC ID 作为 Subscription ID。

例如:

text
subscriptions/listen id = 51

那么后面的:

text
notifications/resources/updated

都会携带:

text
subscriptionId = 51

这在 stdio 里尤其重要。

因为 stdio 所有消息都可能交错在同一个 Channel:

text
Subscription 51 → Resource Updated

普通 tools/call Response

Subscription 72 → Tools List Changed

Subscription 51 → Resource Updated

Client 必须根据:

text
subscriptionId

做 Demultiplexing(把混在一起的消息重新分流)。

所以这里的关系和普通 Request 很像:

text
Request ID
负责关联 Request / Response

Subscription ID
负责关联长期 Stream 上的一组 Notification

只是 Subscription 直接借用了最初 Request ID,避免再额外生成一套独立 Identifier。

Connection 断开以后,Subscription 为什么不能自动恢复?

如果 stdio Process 重启:

text
旧 Connection 结束

新 Connection 建立

Client 不能假设:

text
Server 还记得我刚才监听了哪些 Resource。

当前规范明确要求重新发送:

text
subscriptions/listen

原因并不复杂。

Subscription 本质上属于:

text
当前 Communication Channel(通信通道)上的长期监听关系。

它不是 Durable State(可持久化状态)。

所以Connection断开,意味着 Subscription 也失效。

这和后面要讲的 Tasks 正好形成非常清晰的边界。

如果某项工作要求:

text
Client 断线一天以后回来,仍然能找到它。

那就不应该依赖 Subscription 或一条一直挂着的 Request。

因为 Progress、Cancellation、Subscription 解决的都是:

text
当前通信关系还存在时,怎样管理正在运行的工作和通知。

它们都依赖一个仍然活着的 Request 或 Connection。

而真正能够脱离当前 Connection 长期存在的工作,需要另外一种模型:

Task。(后文介绍)

所以这一篇里真正应该理解的是三个不同生命周期:

text
Progress / Cancellation

属于当前 In-flight Request

Subscription

属于当前长期 Notification Stream

Task

工作可以脱离当前 Request / Connection 独立存在

MCP 把它们拆开,是因为:

text
“执行时间很长”并不等于“它们应该拥有同一种生命周期”。

一个 Tool 运行 40 秒,可以继续保持普通 Request,并通过 Progress 报告状态。

一个 Resource 未来可能发生变化,应该建立 Subscription。

一个可能运行几个小时、Client 中途可以断线再回来查看的任务,则不应该靠前两种机制硬撑。

总结

MCP 中“执行时间很长”并不对应一种统一的处理方式。关键要先判断:当前工作仍然属于一条正在执行的 Request,还是需要在 Request 结束以后继续接收未来发生的事件。

如果一条 Request 本身需要运行较长时间,MCP 可以通过 Progress 向 Client 报告执行状态。Client 如果希望接收进度,会在 Request 的 _meta 中携带 progressToken。Server 随后可以通过 notifications/progress 返回当前 progress、可选的 totalmessage

progressToken 和 JSON-RPC Request ID 的职责并不相同。Request ID 用来关联 Request 和最终 Response,而 progressToken 用来表明 Client 愿意接收这条 Request 的 Progress,并负责关联后续 Progress Notification。它只需要在当前 Active Requests 中唯一,不应该被当成长期的 Task ID、Run ID 或 Trace ID。

Progress 也不能代替最终 Response。即使 Server 已经报告 100 / 100,只要最终 Response 还没有返回,这条 Request 就仍然没有完成。

对于长时间请求,Timeout 和 Progress 通常需要配合使用。实现可以在收到有效 Progress 后重置普通 Timeout,因为这表明 Server 仍然在工作;但仍然应该设置 Maximum Total Timeout,限制整条 Request 或整个调用流程能够存活的最长时间,避免异常 Server 通过不断发送 Progress 无限延长 Request 生命周期。

Cancellation 则解决“发起方已经不想继续执行当前 Request”的问题。具体取消信号取决于 Transport:

  • stdio 中,多条 Request 共享同一通信通道,因此 Client 需要发送 notifications/cancelled,通过 requestId 指明要取消哪条 Request。
  • Streamable HTTP 中,每条 Request 拥有独立的 Response Stream,因此 Client 关闭当前 SSE Response Stream,本身就是对这条 Request 的 Cancellation。

Cancellation 是一种尽力而为的协议语义,而不是业务事务保证。取消信号到达 Server 以前,操作可能已经完成,因此“Client 发出了取消”并不等于“业务副作用一定没有发生”。对于支付、删除、部署等操作,如果需要真正的 Cancel、Rollback 或补偿机制,仍然需要业务系统自己实现。

对于与某一条 Request 无关、可能在未来持续发生的事件,MCP 不应该一直占用普通 Request,而是使用 subscriptions/listen 建立长期 Notification Stream。

Client 在建立 Subscription 时通过 Filter 指定自己愿意接收的通知,例如 Tool List Changed、Prompt List Changed、Resource List Changed 或某些具体 Resource 的更新。Server 不能借助这条长期 Stream 随意推送 Client 没有请求的通知。

Subscription 建立以后,Server 必须首先发送 notifications/subscriptions/acknowledged,告诉 Client 实际接受了哪些订阅条件。随后这条 Subscription 上的通知都会携带:

io.modelcontextprotocol/subscriptionId

它直接使用最初 subscriptions/listen 的 JSON-RPC Request ID,使 Client 能够在多个并发 Subscription 共用通信通道时正确完成消息分流。

Subscription 也不是 Durable State。底层 Transport 或 Connection 断开以后,原来的 Subscription 生命周期随之结束,重新连接后需要重新建立监听关系。

因此,这篇文章最重要的不是记住几个 Method,而是区分不同的生命周期:

Progress 和 Cancellation 管理正在执行的 Request;Subscription 管理长期 Notification Stream;真正需要脱离当前 Request 和 Connection 独立存在的长期工作,则需要另外的 Task 模型。

相关面试题

  • MCP 是怎么处理长时间运行的 Request 的?
  • MCP 的 progressToken 有什么作用?它和 JSON-RPC Request ID 有什么区别?
  • 为什么收到 notifications/progress 不能认为 Request 已经完成?
  • MCP 中 Timeout、Progress 和 Cancellation 是什么关系?
  • 为什么收到 Progress 后可以重置 Timeout,但仍然需要 Maximum Total Timeout?
  • MCP 在 stdio 和 Streamable HTTP 中分别是怎么取消 Request 的?为什么两种 Transport 的取消方式不同?
  • 为什么说 MCP Cancellation 只能尽力取消,而不能保证业务操作一定没有发生?
  • subscriptions/listen 是什么?它和普通 Request-scoped SSE 有什么区别?
  • 为什么 subscriptions/listen 建立后必须先收到 notifications/subscriptions/acknowledged
  • subscriptionId 是什么?为什么 MCP 直接复用 subscriptions/listen 的 Request ID?
  • Connection 断开以后,MCP Subscription 为什么需要重新建立?
  • Progress、Subscription 和 Task 分别对应什么样的生命周期?