MCP 会话断了怎么续:微软 AI Agent 入门课的 resumable client
长任务跑到一半,客户端被 Ctrl+C 掉了,或者网络断了。重新起客户端,屏幕上打出「Resuming existing session…」,然后就没有然后了——没有进度日志,没有报错,也没有任务完成。读 ai-agents-for-beginners 第 11 课那套 MCP 示例的代码就会发现,有好几条路径都会导向这种「看着像在恢复、实际什么也没恢复」的结果。
这一课的示例代码在 11-agentic-protocols/code_samples/mcp-agents/ 下,自带一份 README。顺带说一句,课程正文 11-agentic-protocols/README.md 里我们没有找到指向这份示例的说明,所以第一次翻到它多半是靠目录,不是靠正文。
下面按排查的顺序走:先确认是不是「续跑」这一环出的问题,再看仓库代码里给出的处置,最后说清楚什么情况下根本不该往这个方向查。
一、先看客户端读的是哪个 token 文件
示例里有两个客户端,各自管各自的 token,文件名还不一样。
client/resumable_client.py 顶上写死的是:
# Token file location
TOKEN_FILE = Path(".mcp_resumption_token.json")
而交互式客户端 client/client.py 用的是 client/utils.py 里的 TokenManager,它的默认值是:
DEFAULT_TOKEN_FILE = "resumption_tokens.json"
两个文件名都是仓库当前代码里的取值,随版本可能变动。要点是:用 client.py 起的任务,拿 resumable_client.py 去续,读的压根不是同一个文件,于是走的是「没有 token」的新会话分支,任务从头开始,而屏幕上不会有任何报错。
两个路径都是相对路径,相对进程的工作目录。Windows 侧这一条更容易踩:你在 PowerShell 里 cd 到 mcp-agents 目录起了服务端,又开了一个终端在别的目录起客户端,token 文件就落在了另一个地方;Linux/macOS 同理,只是习惯上大家更容易保持在同一个目录里。所以判定动作很简单:到你实际启动客户端的那个目录下,看这个 JSON 文件在不在。
resumable_client.py 存进去的字段是固定的一组:
tokens = {
"session_id": session_id,
"resumption_token": resumption_token,
"protocol_version": protocol_version,
"tool_name": "long_running_agent",
"tool_args": {}
}
注意 tool_name 是写死的 long_running_agent——这个精简客户端只负责续这一个工具。想续 travel_agent 那种带 elicitation 的流程,是 client.py 的活,它的 TokenManager.save_tokens() 才会把 last_tool / last_args 存下来。
二、再看服务端有没有开事件存储
续跑靠的是服务端的事件存储。server/server.py 的 run_server() 里这一行决定了有没有:
event_store = SimpleEventStore() if with_event_store else None
对应的命令行开关是 --no-event-store。启动后日志会二选一地打出 Event store enabled - resumption supported 或 Event store disabled - no resumption support,main() 里调了 logging.basicConfig(level=logging.INFO),所以这两行看得到。
反过来,客户端把日志压得很狠。resumable_client.py 里 logging.basicConfig(level=logging.WARNING),还把 mcp.client.streamable_http 单独设成 ERROR,另外挂了一个过滤器:
class SSEFilter(logging.Filter):
def filter(self, record):
return not (
"Error parsing SSE message" in record.getMessage() or
"Invalid JSON: EOF while parsing" in record.getMessage()
)
也就是说,客户端这边「安静」不等于「正常」。排查时把判断依据放在服务端日志上,比盯客户端屏幕靠谱。
三、被存下来的到底是什么
在翻存储实现之前,先弄清存的是什么,否则「重放」这个词很容易被想象成「任务从断点接着算」。
server/server.py 里 long_running_agent 的循环体,每一步做的是发一条日志通知:
await ctx.session.send_log_message(
level="info",
data=f"Processing step {current_step}/{steps} ({progress_percent}%)",
logger="long_running_agent",
related_request_id=ctx.request_id,
)
travel_agent 与 research_agent 走的则是 send_progress_notification(),参数里带 progress_token=ctx.request_id。这些从服务端流向客户端的消息,就是会经由传输层落进 event store 的东西——存的是消息,不是任务的执行状态。任务本身仍然跑在服务端那个协程里,客户端断开期间它该跑还在跑;重连之后补上来的,是断开这段时间里错过的那些通知。
这也解释了 store_event(stream_id, message) 为什么第一个参数是 stream_id:一个会话里可以有多条流,每条流对应一次带响应流的请求。下一节里两个实现在「重放范围」上的分歧,分歧点就在这里。
四、事件存到哪、重放哪一段
server/event_store.py 里有两个实现,都继承 EventStore,接口是同一对方法:store_event(stream_id, message) 返回 EventId,replay_events_after(last_event_id, send_callback) 返回 StreamId | None。
SimpleEventStore 的存储就是一个列表,类文档字符串自己写明是 “Simple in-memory event store for testing resumption functionality”——用于测试。它的 id 是一个自增计数器转成的字符串。重放时线性扫这个列表找 last_event_id,关键在找不到的那一支:
if start_index is None:
# If event ID not found, start from beginning
start_index = 0
logger.info("Event ID not found, starting from beginning")
两件事由此确定。其一,服务端进程一重启,列表就空了,客户端手里那个 token 对应的 id 在新进程里当然找不到,于是 start_index = 0,而空列表从 0 开始也没有东西可发,日志会是 Replayed 0 events, stream_id: None。其二,它的重放循环遍历的是 self._events[start_index:] 全部内容,没有按 stream 过滤;stream_id 只是从第一条被重放的事件上取一次。
PersistentEventStore 落 SQLite,表结构就三列:
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stream_id TEXT NOT NULL,
message TEXT NOT NULL
)
消息体用 TypeAdapter(JSONRPCMessage) 序列化成 JSON 存进 message 列,读回来时 validate_json 反序列化。数据库操作放在 asyncio.to_thread 里执行,代码注释写的理由是 “Run DB operation in thread pool to avoid blocking event loop”,连接也因此带了 check_same_thread=False。
它的重放范围和 SimpleEventStore 明显不同,_fetch_events_sync() 分两步:
cursor.execute("SELECT stream_id FROM events WHERE id = ?", (target_id,))
cursor.execute(
"SELECT id, stream_id, message FROM events WHERE id > ? AND stream_id = ? ORDER BY id ASC",
(target_id, stream_id)
)
先用 last_event_id 反查它属于哪个 stream,再只取这个 stream 里 id 更大的那些。所以同样是「断线重连」,两个实现给出的重放集合可能完全不是一回事:内存版把之后的所有事件都发一遍,SQLite 版只发同一条流的。
边界处理也是反的。SQLite 版遇到 id 不是整数会记 Invalid event ID format 并返回 None,查不到这条 id 会记 Event ID {target_id} not found 同样返回 None,上层随即打出 Could not resume stream from event {last_event_id}——明确失败;内存版则是悄悄从头再来。排查时这两种日志形态的区别,比任何猜测都直接。
还有两个细节值得记:反序列化失败的那条事件会被 except 掉、只记一行 Failed to deserialize event,重放继续往下走,客户端那边就是中间少了一段;clear_events() 除了 DELETE FROM events,还会 DELETE FROM sqlite_sequence WHERE name='events' 把自增序列归零,而这张表里没有任何区分「哪一次运行」的字段,只能按 id 查——清库之后 id 会从头再来,客户端手里的旧 token 与新库里的 id 是否会撞上,代码里没有做区分。
顺带一提:server.py 只 from .event_store import SimpleEventStore,PersistentEventStore 在仓库里没有被任何地方引用。想用它得自己接,接口是现成的,create_server_app() 的签名是 (event_store: Optional[EventStore] = None) -> Starlette:
from .event_store import PersistentEventStore
from .server import create_server_app
with PersistentEventStore("events.db") as event_store:
app = create_server_app(event_store)
"events.db" 是 PersistentEventStore.__init__ 里 storage_path 的默认值,仓库当前代码如此,随版本可能变动;它同样是相对路径,Windows 上换个工作目录启动就是另一个库文件。上面这段为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
五、客户端恢复时到底做了什么
resumable_client.py 有 token 时的动作有三处,都值得单独看。
第一,把会话信息塞进请求头:
headers[MCP_SESSION_ID_HEADER] = existing_tokens["session_id"]
headers[MCP_PROTOCOL_VERSION_HEADER] = existing_tokens.get("protocol_version", "2025-03-26")
这两个常量是从 mcp.server.streamable_http 导入的。
第二,连接时 terminate_on_close=False,代码里紧跟的注释就是 # Enable resumption——不关闭会话,服务端那边才留得住。
第三,有 token 时跳过 session.initialize(),走的是 print("✅ Using existing session (no initialization needed)") 这一支;只有全新会话才初始化。
六、怎么算续上了
三个信号一起看:服务端出现 Replaying events after {id} 且随后的 Replayed N events 里 N 大于 0;客户端 message_handler 开始打 📡 [long_running_agent] ...(这是 LoggingMessageNotification 分支的输出,long_running_agent 的进度是用 send_log_message 发的,不是进度通知);任务正常结束后 clear_resumption_tokens() 会把 token 文件删掉。
反过来,任务结束了但 token 文件还在,说明走的是 except 那一支,注释写着 # Keep tokens in case we want to retry。这时候可以用 --clear-tokens 清掉重来,它和 --url 一起在 main() 的 argparse 里定义,--url 的默认值是 http://127.0.0.1:8006/mcp,这是仓库当前代码里的默认值,随版本可能变动;服务端 main() 那边 --port 的默认值与之对应。
七、什么情况说明不是这个原因
- 服务端日志里一条
Stored event ... for stream ...都没有:事件根本没进存储,问题在连接或传输层,不在重放。create_server_app()里的TransportSecuritySettings只放行allowed_hosts=["127.0.0.1:*", "localhost:*"]与对应的http://origin,你换个主机名或 IP 连过去,卡住的是这一关。 - token 文件压根没生成:
TokenManager.save_tokens()会先挡掉空的session_id/resumption_token,也会挡掉以pending_或temp_开头的占位串并打印Cannot save placeholder token。这是「token 没拿到」,与重放范围无关。 - 第一次断线能续、第二次续不上:看
resumable_client.py里那个 if/else——只有新任务分支把on_resumption_token_update挂进ClientMessageMetadata,走恢复分支时metadata里只有resumption_token,没有更新回调。也就是说恢复期间不会再写新 token,文件里躺的还是上一次那个。这属于客户端示例的行为,不是事件存储的问题。 - 你续的不是
long_running_agent:resumable_client.py的请求参数写死name="long_running_agent", arguments={},别的工具它不发。
最后一句提醒:这套东西横跨课程仓的示例代码和 MCP Python SDK 两层,token 在 SDK 内部怎么映射成重放用的 last_event_id,示例代码里没有写,别在这一层上想当然。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。