MCP 连不上:微软 AI Agent 入门课 client 端连接流程逐步核
第 11 课 11-agentic-protocols 里的 code_samples/mcp-agents/ 是整个课程仓里少见的、把 MCP 客户端与服务端都写全的一份示例。它不是那种「一个装饰器注册工具」的玩具,客户端里塞了 message_handler、sampling_callback、elicitation_callback 三个回调,还有一整套 resumption token 的存取逻辑。代码复杂度上来了,连不上的姿势也就跟着变多——终端上打出来的那行 ❌ Connection error,背后可能是完全不同的五件事。
这篇把 client/client.py 的连接链路按代码顺序拆开,每一段告诉你怎么判定卡在这里、仓库代码给出的处置是什么、以及什么情况下说明根本不是这个原因。
先看清一次连接被拆成了几段
interactive_mode(server_url) 里,从进函数到能敲命令,代码顺序是固定的五段:
token_manager = TokenManager()后load_tokens(),根据结果决定headers;async with streamablehttp_client(...)建传输,解包出(read_stream, write_stream, get_session_id);async with ClientSession(read_stream, write_stream, message_handler=..., sampling_callback=..., elicitation_callback=...)建会话;- 分叉:token 齐全走 resumption 分支(跳过 initialize),否则
await session.initialize(); await session.list_tools(),再display_tools(tools)打出工具清单。
这里有个很要命的细节:第 1 段的 token 读取在 try 之外,从第 2 段起的后四段被同一个 try 整个包着,函数末尾只有一句
except Exception as e:
console.print(f"[red]❌ Connection error: {e}[/red]")
也就是说,从建传输那一步往后的任何异常——包括跟网络毫无关系的——都会被打成 Connection error。看到这个前缀先别定性,先看冒号后面那段原文,再对照下面判断卡在第几段。
段一:残留的 token 文件让客户端跳过了 initialize
现象:终端先打出 🔄 Found existing session, attempting resumption...,然后要么长时间没有下文,要么直接 ❌ Resume failed:。
怎么确认:看 utils.py 里 DEFAULT_TOKEN_FILE = "resumption_tokens.json",TokenManager.__init__ 默认用的就是它,而且是相对路径——文件落在你当前工作目录下。到你启动客户端的那个目录里看有没有这个文件。
只要它存在且含 session_id 与 resumption_token,load_tokens() 就会返回数据,接着这段代码会把服务端的会话头直接塞进 HTTP 请求:
headers = {}
if existing_tokens:
headers[MCP_SESSION_ID_HEADER] = existing_tokens["session_id"]
if "protocol_version" in existing_tokens:
headers[MCP_PROTOCOL_VERSION_HEADER] = existing_tokens["protocol_version"]
更关键的是第 4 段的判断:当 resumption_token、last_tool、last_args 三样都在时,代码走的是「✅ Skipping initialization, directly resuming…」这条路,session.initialize() 压根不会被调用。你的服务端如果重启过,那个 session id 在它那边早就不存在了,客户端却还拿着旧头去续命。
处置:仓库自己给了三个口子——命令行 --clean-tokens(main() 里对应 parser.add_argument("--clean-tokens", action="store_true", ...),处理完直接 return,不进交互)、交互模式里输入 clean-tokens 或 clean、或者手工删掉那个 json。
★ 这里有个容易漏的坑:同目录下的 resumable_client.py 用的是另一个常量 TOKEN_FILE = Path(".mcp_resumption_token.json"),跟 client/utils.py 的 resumption_tokens.json 不是同一个文件。你清了其中一个,另一个脚本的残留状态还在。两个文件名都是从仓库代码里逐字抄下来的,以仓库最新内容为准。
处置后怎么验证:重新起客户端,load_tokens() 走的是文件不存在分支,会打 No resumption token file found (resumption_tokens.json);接着应该出现 🔌 Connecting to server...,然后是 ✅ Connected to: 加上 result.serverInfo.name、📋 Protocol: 加上 result.protocolVersion。这三行连着出现,说明第 4 段的正常分支走通了。
什么情况说明不是这个原因:如果你从头到尾没见过 🔄 Found existing session 这行,那 existing_tokens 就是 None,headers 是空的,问题不在 token 上,往下看。
段二:URL、路径与监听地址
现象:❌ Connection error: 后面跟的是连接被拒绝一类的原文,而且出现得很快,前面连 🔌 Connecting to server... 都没打出来(那行在第 4 段才打)。
怎么确认,三处逐字对齐:
- 客户端默认值:
parser.add_argument("--url", default="http://127.0.0.1:8006/mcp", help="MCP server URL"); - 服务端默认端口:
parser.add_argument("--port", type=int, default=8006, ...); - 服务端路由:
Mount("/mcp", app=session_manager.handle_request)。
上面这两个默认值是仓库当前代码里写的,随版本可能变动,别当成固定约定记。
路径 /mcp 是挂载点,不是可以省的后缀。URL 只写到端口,请求打不到 session_manager.handle_request 上。README 的 Getting Started 一节给的两条命令就是这个对应关系:
# Start the server with event store for resumption
python -m server.server --port 8006
# In another terminal, run the interactive client
python -m client.client --url http://127.0.0.1:8006/mcp
再看监听地址。run_server() 里的 uvicorn.Config 把 host="127.0.0.1" 写死了,而 main() 的 argparse 只暴露了 --port 和 --no-event-store,没有 host 这个可调项。同时 create_server_app() 里配了传输层安全设置:
security_settings = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
两处叠在一起,这份示例覆盖的就是同机回环这一种场景。你在 Windows 上起服务端、在 WSL 里跑客户端,或者换成局域网 IP、机器名去连,都超出了它写明的范围——这不是 bug,是示例给自己划的边界。
Windows 侧的定位动作:netstat -ano | findstr 8006 看端口有没有被监听、被谁占;Linux/macOS 侧用 lsof -i :8006。这两条是通用排查做法,不是该项目官方内容,端口号换成你实际用的那个。
处置:把 URL 补全成带 /mcp 的形式;服务端换端口时,客户端 --url 必须同步改。跨机访问不在示例范围内,别指望改 URL 能绕过去。
验证:能看到 ✅ Connected to: 与 📋 Protocol: 两行,再往下 📋 Loading available tools... 后打出工具清单,就算通了。
什么情况说明不是这个原因:如果 ✅ Connected to: 已经打出来了,传输和路由就都没问题,后面再报错是工具调用层的事。
段三:报错被日志过滤器吞掉了
现象:❌ Connection error: 后面那段原文语焉不详,或者干脆什么线索都没有。
怎么确认:interactive_mode 一进来就先降噪——logging.getLogger('mcp.client.streamable_http').setLevel(logging.ERROR),然后定义了一个 SSEFilter,在 filter() 里对含 "Error parsing SSE message"、"ValidationError"、"EOF while parsing" 的记录一律 return False,并把它挂到 ['mcp.client.streamable_http', 'mcp', 'pydantic_core'] 三个 logger 上。resumable_client.py 顶部有一份类似的过滤。
传输侧用的是 StreamableHTTP,服务端 StreamableHTTPSessionManager 里写着 json_response=False, # Use SSE streams——也就是说,SSE 流上的解析异常正是这条链路最可能报的东西,而它恰好在被过滤名单里。
处置:排查期间临时把这两处降噪注释掉(setLevel 那行和 logger.addFilter(SSEFilter()) 那行),让底层日志露出来。这是改代码,不是加参数,改完记得还原。
顺带一提,message_handler 内部是 except Exception: pass,注释写的是 “Silently ignore message handler errors”;服务端推送的进度与日志通知如果解析出问题,你这边是静默的。
什么情况说明不是这个原因:如果报错原文已经很具体(比如明确指向某个 HTTP 状态或某个字段),日志过滤就不是障碍,别在这上面浪费时间。
段四:不是连接问题的那几种
这一节是本文最该被记住的部分。以下现象都不该按「连不上」去查:
其一,RuntimeError: No session ID available - resumption requires a valid session。 它来自 execute_tool_with_resumption 的第一行——get_session_id() 返回空。能走到这个函数,说明传输已经建起来了,是会话 id 没拿到,方向完全不同。
其二,黄色的 ⚠️ Tool completed with connection issues, but likely succeeded。 代码里对工具异常做了字符串判断:只有当 "ValidationError" in error_msg and "JSON" in error_msg 同时成立,才走这条「可能已成功」的分支,并顺手 token_manager.delete_tokens()。这是示例作者对某类已知噪音的兜底,不是连接失败。
其三,❌ Resume failed: 之后紧跟 Falling back to normal initialization...。 这条路径后面接的是完整的 session.initialize() + list_tools(),也就是说 resume 挂了还有退路。真正的连接问题是连 fallback 都走不完。
其四,token 文件从来没生成过。 正常流程那段里,注释写着 “Save protocol version for future resumption”,但紧跟的代码只做了一件事——if current_session_id: 时打印一行 🆔 Session:,并没有调用 save_tokens。注释与代码在这里对不上。token 文件真正被写出来,只发生在 enhanced_callback(token) 被触发时,而 TokenManager.save_tokens 还会拦掉空值和 pending_ / temp_ 开头的占位串。所以「连上了但没有 token 文件」是这份代码本来的样子,不是故障。
其五,服务端带了 --no-event-store。 这个开关会让 run_server() 里的 event_store 为 None,日志打 Event store disabled - no resumption support。这时 resume 相关的一切都不成立,问题在启动参数,不在网络。
最后一句实在话:这份示例的客户端把「初始化」「续接」「工具调用」三件事编在了同一个 try 里,读报错时先定位自己停在哪一段,比反复重启有用得多。以上判断依据均来自仓库当前代码,该课程持续更新,文件路径与接口写法随版本变动,以仓库最新内容为准。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。