MCP 的长任务进度回报,关键不是让服务端“主动广播”任意消息,而是让客户端先在请求的 _meta.progressToken 中留下一个关联标识。服务端只有拿到这个标识,才把处理中间状态封装成 notifications/progress 发回同一请求的客户端。实现时抓住三点:token 必须在活动请求中唯一,progress 对同一个 token 必须持续递增,任务结束后不能继续使用该 token。
官方规范地址:https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress
下面用一个“批量处理文件”的工具说明请求字段、服务端处理器和客户端回调如何配合。示例是协议结构示意,读者可以把处理文件替换成模型调用、索引构建或批量检索。
progress token 解决什么问题
普通工具调用通常只返回最终结果,长任务期间客户端不知道任务是否仍在工作。MCP 的进度机制把一次请求和多条进度通知关联起来:客户端在请求中放入 token,服务端在每次有进展时复用同一个 token,客户端据此更新对应任务的进度条。

需要注意,进度是可选能力。客户端没有发送 progressToken 时,服务端可以只返回最终结果;服务端也可以因为任务很快完成而不发送任何中间通知。不要把“没有进度通知”直接判断成任务失败。
请求和通知字段怎么对应
请求侧的 token 是不透明值,服务端不需要从中编码业务状态。它只负责原样带回通知。通知中的 progress 表示当前进度,total 在总量已知时提供分母,message 用于给用户展示简短的人类可读说明。
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "process-files",
"arguments": {"files": ["a.csv", "b.csv", "c.csv"]},
"_meta": {"progressToken": "job-7"}
}
}
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "job-7",
"progress": 2,
"total": 3,
"message": "已处理 2 个文件"
}
}
严格 JSON 不能插入注释,因此字段含义放在代码块外解释:job-7 只用于关联请求,2/3 代表当前已完成数量,通知中的 token 必须与原请求一致。

服务端怎样实现最小回报
服务端处理器先读取请求元数据,再在每个工作单元完成后发送通知。没有 token 时直接跳过通知;有 token 时保证数值单调递增。示例中的 SDK 调用名采用 TypeScript SDK v2 的请求上下文写法,核心逻辑也适用于其他语言 SDK。
server.registerTool(
"process-files",
{ inputSchema: z.object({ files: z.array(z.string()) }) },
async ({ files }, ctx) => {
// 只有客户端明确请求进度时,才发送中间通知。
const token = ctx.mcpReq._meta?.progressToken;
for (let index = 0; index
这里有两个容易漏掉的判断。第一,必须用 !== undefined 判断是否请求了进度,不能把数字 token 0 当成未启用。第二,最终结果和进度通知是两条不同的消息职责:通知用于更新状态,工具结果用于承载最终内容。
客户端如何接收并处理边界情况
客户端通常通过 SDK 的进度回调发起调用。回调只应更新当前请求对应的界面状态;如果 total 缺失,就显示不确定进度,而不是把它当作除数。若服务端重复发送相同或倒退的 progress,客户端可以记录协议异常并保留最后一个较大值,避免进度条跳回。
const result = await client.callTool(
{ name: "process-files", arguments: { files } },
{
// SDK 会把回调选项转换成请求中的 progressToken。
onprogress: update => {
const percent = update.total
? Math.round((update.progress / update.total) * 100)
: undefined;
renderProgress({
value: percent,
message: update.message ?? "任务处理中"
});
}
}
);
// 最终结果在调用完成后处理,不依赖最后一条通知。
renderResult(result.content);
兼容处理可以分成三层:不支持进度的客户端仍等待最终结果;服务端没有总量时使用 indeterminate 状态;长循环还要检查取消信号,并把取消信号传给底层 I/O,避免客户端已经停止而服务端继续消耗资源。
性能和安全上,不要为每个字节或每条日志发送一次通知,应按工作单元或时间窗口限流;服务端只接受活动请求中出现过的 token,任务结束后立即停止引用;token 本身不要携带密钥、用户隐私或可推断的业务数据。
常见问题
没有 progressToken 时必须发送进度吗?
不需要。进度通知是可选的,服务端可以只返回最终工具结果。
total 不知道时 progress 应该怎么填?
仍然发送不断递增的当前进度,同时省略 total,客户端显示不确定进度。
进度通知能代替最终结果吗?
不能。通知负责状态更新,最终响应负责返回工具调用的结果或错误。