MCP 连不上:微软 AI Agent 入门课 client 端连接流程逐步核

2026-08-18

第 11 课 11-agentic-protocols 里的 code_samples/mcp-agents/ 是整个课程仓里少见的、把 MCP 客户端与服务端都写全的一份示例。它不是那种「一个装饰器注册工具」的玩具,客户端里塞了 message_handlersampling_callbackelicitation_callback 三个回调,还有一整套 resumption token 的存取逻辑。代码复杂度上来了,连不上的姿势也就跟着变多——终端上打出来的那行 ❌ Connection error,背后可能是完全不同的五件事。

这篇把 client/client.py 的连接链路按代码顺序拆开,每一段告诉你怎么判定卡在这里、仓库代码给出的处置是什么、以及什么情况下说明根本不是这个原因。

先看清一次连接被拆成了几段

interactive_mode(server_url) 里,从进函数到能敲命令,代码顺序是固定的五段:

  1. token_manager = TokenManager()load_tokens(),根据结果决定 headers
  2. async with streamablehttp_client(...) 建传输,解包出 (read_stream, write_stream, get_session_id)
  3. async with ClientSession(read_stream, write_stream, message_handler=..., sampling_callback=..., elicitation_callback=...) 建会话;
  4. 分叉:token 齐全走 resumption 分支(跳过 initialize),否则 await session.initialize()
  5. 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.pyDEFAULT_TOKEN_FILE = "resumption_tokens.json"TokenManager.__init__ 默认用的就是它,而且是相对路径——文件落在你当前工作目录下。到你启动客户端的那个目录里看有没有这个文件。

只要它存在且含 session_idresumption_tokenload_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_tokenlast_toollast_args 三样都在时,代码走的是「✅ Skipping initialization, directly resuming…」这条路,session.initialize() 压根不会被调用。你的服务端如果重启过,那个 session id 在它那边早就不存在了,客户端却还拿着旧头去续命。

处置:仓库自己给了三个口子——命令行 --clean-tokensmain() 里对应 parser.add_argument("--clean-tokens", action="store_true", ...),处理完直接 return,不进交互)、交互模式里输入 clean-tokensclean、或者手工删掉那个 json。

★ 这里有个容易漏的坑:同目录下的 resumable_client.py 用的是另一个常量 TOKEN_FILE = Path(".mcp_resumption_token.json"),跟 client/utils.pyresumption_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.Confighost="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_storeNone,日志打 Event store disabled - no resumption support。这时 resume 相关的一切都不成立,问题在启动参数,不在网络。

最后一句实在话:这份示例的客户端把「初始化」「续接」「工具调用」三件事编在了同一个 try 里,读报错时先定位自己停在哪一段,比反复重启有用得多。以上判断依据均来自仓库当前代码,该课程持续更新,文件路径与接口写法随版本变动,以仓库最新内容为准。

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。


本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。