
最近好几个技术群里都在聊 MCP有人卡在握手阶段报错有人问 LangGraph 里怎么同时挂多个 MCP Server还有人已经拿 MCP 接到 UE5、数据库甚至二进制分析工具里了。但大部分讨论都停在“怎么把一个 Server 配起来”这一步一旦涉及到协议层的细节、多 Server 的编排调度就各种踩坑。我前阵子刚好在做一个基于 LangGraph 的多 Agent 工程里面同时接了三个职责完全不同的 MCP Server一个负责查数据库一个负责操作在线文档还有一个负责读日志和本地文件。过程中把 MCP 从协议握手到工具调用的整个链路都摸了一遍踩了不少文档里根本不会写的坑。这篇就把整个链路拆开讲清楚先从协议层面分析一次完整的握手过程再说 MCP 工具在 Agent 里是怎么被发现和调用的最后落到 LangGraph 里的多 Server 调用方案代码、参数、排查思路都放在里面。适合正在研究 MCP、想把多个 MCP Server 集成进自己 Agent 系统的朋友。1. MCP 到底是什么协议模型与应用场景1.1 它解决的痛点从“N 个模型 × M 个工具”到统一接口在没有 MCP 之前让大模型去调用一个外部工具基本是各干各的有的框架用 Function Calling有的用插件机制有的直接让模型生成一段代码再去执行。每接一个新工具都要写一套适配代码而且换一个 Agent 框架这套代码大概率要重写。这就像家里买了一堆电器但每个电器都带一个专用插座换一个牌子的插线板就全部作废。MCP 做的事情就是定义一个“标准插座”工具提供方按照统一协议把能力暴露成 Server模型应用侧按照统一协议去连接和调用。只要两边都遵守这个协议工具和 Agent 就可以任意组合不再关心对方内部是怎么实现的。做过几年后端的朋友应该马上能反应过来这本质上就是“接口标准化”的思路和 HTTP、JDBC 解决的问题一模一样只不过这次是给大模型和外部世界之间定义的接口标准。1.2 核心角色Host、Client、Server 各司其职MCP 的架构里有三个角色很多初学的朋友容易搞混我用一句话帮大家理清Host 是“宿主程序”也就是你正在跑的 Agent 框架、IDE 或者业务系统它负责管理整个会话和上下文。Server 是“能力提供方”比如一个数据库查询服务、一个文档处理服务它以独立进程或远程服务的形式存在把能力包装成标准接口暴露出来。Client 是“连接器”它跑在 Host 里面负责和 Server 建立连接、收发消息。一个 Host 可以同时持有多个 Client每个 Client 连接一个 Server。可以这么理解Host 是餐厅Server 是后厨Client 是传菜员。你用户坐在餐厅里点菜传菜员根据菜单工具列表去后厨把菜端过来整个过程有一个统一的传菜标准不会因为换了后厨就乱套。1.3 传输方式与能力面stdio 和 Streamable HTTP 怎么选MCP 的通信底层有两种常用传输方式。本地场景用 stdioHost 通过子进程启动 Server用标准输入输出传 JSON 消息简单高效适合把 Python 脚本、命令行工具包装成 MCP Server。跨机器或独立部署场景用 Streamable HTTPServer 跑成一个 HTTP 服务Host 通过 HTTP 请求去调用适合多客户端共享同一个 Server 的情况。选型建议很简单所有东西都在同一台机器上优先用 stdioServer 要部署到远程或要供多个客户端复用就用 Streamable HTTP。两种方式之上跑的是同一套 MCP 协议所以后面讲的握手、工具调用流程都是一样的。MCP Server 能暴露的能力面有三类Tools工具让模型执行动作、Resources资源让模型读取数据、Prompts提示模板预置一些 Prompt 片段。这三者才是 MCP 的完整能力模型后面我会重点展开 Tools 和 Resources。2. 协议握手拆解initialize 请求到底在做什么2.1 先搞清楚通信底座JSON-RPC 2.0MCP 的所有通信都建立在 JSON-RPC 2.0 之上。这是一个非常轻量的远程调用协议请求和响应都是 JSON 格式每个请求带一个唯一 id响应里带上同一个 id 来对应。举个例子// 请求 {jsonrpc: 2.0, id: 1, method: initialize, params: {...}} // 响应 {jsonrpc: 2.0, id: 1, result: {...}}另外还有一类消息叫 notification通知它没有 id也不需要响应。MCP 里的 initialized 通知就是典型代表。我见过有的开发者想绕开官方 SDK自己用裸 WebSocket 或 TCP 去实现 MCP 通信结果在 JSON-RPC 的 id 对应关系和 notification 处理上吃了不少亏。建议除非有特殊需求否则直接用官方 SDK把精力放在业务逻辑上。2.2 initialize 请求里到底带了什么一次完整的 MCP 会话Client 连上 Server 后必须先发一个 initialize 请求这是握手的起点。这个请求的 params 里包含三个关键字段protocolVersion、capabilities、clientInfo。protocolVersion 是客户端声明的协议版本号比如一段常见的版本号是“2025-06-18”不要把它跟 SDK 的版本号搞混。capabilities 是客户端自身能力的声明比如你支不支持读取 Resources、支不支持采样sampling等。clientInfo 就是客户端自己的名称和版本相当于自我介绍。很多人写握手代码时图省事直接把 protocolVersion 写死成一个字符串这是非常容易踩坑的做法。因为 Server 端有自己的版本支持范围如果两边版本差太多握手直接失败。正确做法是用 SDK 导出的协议版本常量让它跟着 SDK 走升级 SDK 时不会因为版本号对不上而出问题。2.3 握手响应与版本协商Server 收到 initialize 请求后会在响应里返回自己的 protocolVersion、capabilities、serverInfo。这里最有意思的是版本协商机制Server 会检查客户端声明的版本自己支不支持如果不支持它会尝试返回一个自己支持的、兼容的版本如果两边版本差距实在太大Server 会直接在错误信息里告诉你协议版本不兼容。我用一段极简代码模拟一下这个过程把一次握手看的明明白白。这里直接通过 subprocess 启动一个 MCP Server然后手动拼一个 initialize 请求发过去import json import subprocess proc subprocess.Popen( [python, demo_server.py], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, textTrue, ) req { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-06-18, capabilities: {}, clientInfo: {name: handshake-demo, version: 1.0.0}, }, } proc.stdin.write(json.dumps(req) \n) proc.stdin.flush() line proc.stdout.readline() print(握手响应:, line) proc.terminate()正常情况下你会看到响应里带回了 Server 选定的 protocolVersion以及它声明的 capabilities 和 serverInfo。如果服务器发现客户端版本太老响应里通常会有对应的错误编号和提示。通过这种方式你可以不依赖任何 SDK 就完成一次 MCP 握手对理解协议本身非常有帮助。2.4 initialized 通知比你想的重要得多握手不是发一个 initialize 就完事了客户端在收到 initialize 响应后必须再发一个 notifications/initialized 通知给 Server告诉它“初始化已完成可以正常进行业务通信了”。很多第一次写 MCP 代码的人会漏掉这一步结果就表现为明明 initialize 成功了但紧接着发 tools/list 请求却超时或者直接被 Server 忽略。原因就是 Server 还在等那个 initialized 通知它认为会话还没有真正建立。我再强调一下initialize 是 request/response有响应initialized 是 notification不需要响应。一个是握手本身一个是握手完成后的“确认信号”两者缺一不可顺序也不能反。2.5 能力协商的真正影响握手阶段声明的 capabilities 不只是礼貌性的自我介绍它直接影响后续请求的可用性。比如说你的客户端在 initialize 里没有声明支持 Resources 能力那么 Server 在收到 resources/list 请求时完全有理由返回空列表或明确报错。这背后的设计逻辑其实很合理Server 根据客户端的能力来裁剪自己的返回内容避免把客户端消化不了的数据一股脑丢过去。所以在排查“某个工具/资源列表突然为空”的问题时第一时间回看握手里客户端 capabilities 是怎么声明的往往比查 Server 配置更有效。3. 工具调用链路从 tools/list 到 tools/call3.1 工具发现模型怎么知道有哪些工具可用握手完成后Client 会调用 tools/list 方法向 Server 获取可用工具列表。Server 返回的每个工具包含三个核心字段name工具名、description工具描述、inputSchema参数约束用 JSON Schema 描述。这里有一个容易被忽略的细节description 的质量直接影响模型调用工具的准确性。我实际测试下来如果 description 里能写清楚参数枚举值、边界条件、返回值结构模型传入非法参数的概率会明显下降。比如一个查询工具如果只在 description 里写“查询订单”模型可能不知道传什么参数如果写成“查询订单status 参数只接受 pending/completed/cancelled返回最近 20 条记录”模型几乎不会出错。3.2 工具调用tools/call 的完整流程当模型决定调用某个工具时Client 会发送一个 tools/call 请求方法名是 tools/call参数里带上工具名和 arguments参数字典。Server 执行完业务逻辑后返回一个 callToolResult里面主要包含 content内容数组和 isError是否是业务错误。这里我要特别提醒一点MCP 协议里业务错误和数据返回在传输层可能都是 200 状态。isError 这个字段才是区分“调用成功但业务没办成”和“调用异常”的关键。我在做日志系统的时候一开始只检查有没有异常结果把业务失败的数据也当成正常数据处理了最后统计数据全偏了。在 LangChain 的 MCP 适配层里isErrortrue 的返回会被包装成一个错误消息回传给模型让模型自己决定怎么处理这个失败的调用结果而不是直接中断整个 Agent 流程。3.3 Resources不只是“读文件”那么简单热词里有人提到“mcp resource 实战”这块确实是 MCP 里最容易忽略的能力。Resources 用 URI 寻址比如 file:///etc/config、db://orders、notion://page/xxxServer 通过 resources/list 暴露资源列表通过 resources/templates/list 暴露动态资源模板。资源模板非常有用它定义了一类资源的 URI 模式比如 file:///{path} 表示“任意路径下的文件”。客户端可以先通过模板了解有哪些资源可读再按需去读具体的数据。在 Agent 里的实际用法是Tools 负责操作和产生副作用Resources 负责提供上下文。比如让一个运维 Agent 排查问题可以先通过 Resources 读取服务配置和日志索引再通过 Tools 去执行具体的数据查询。先“读后生知”再“动手执行”整个链路清晰很多。下面这段代码展示了一个简单的文件读取 Server 如何暴露资源from mcp.server import Server from mcp.server.stdio import stdio_server import mcp.types as types app Server(resource-demo) app.list_resources() async def list_resources(): return [ types.Resource( urifile:///etc/hostname, namehostname, mimeTypetext/plain, description当前机器的主机名文件 ) ] app.read_resource() async def read_resource(uri: str): if uri file:///etc/hostname: with open(/etc/hostname, r) as f: return [types.TextContent(typetext, textf.read())] raise ValueError(fUnsupported resource: {uri})4. LangGraph 集成 MCP多 Server 调用的工程方案4.1 为什么偏偏选 LangGraph市面上的 Agent 框架不少LangGraph 的核心优势在于它是一种“图编排”模型把 Agent 逻辑拆成节点节点之间用边连接支持条件分支、循环和状态持久化。对多 Server 场景来说这个图结构特别有价值因为你可以把“调用数据库 Server”“调用文档 Server”拆成不同的节点让流程控制权掌握在图里而不是全丢给模型的自由发挥。如果只是在 Prompt 里堆一堆工具让模型自己去选Server 少还好Server 一多工具之间就互相干扰模型可能选错工具、可能在一个工具上反复重试、也可能忽略了关键路径上的某个 Server。用 LangGraph 显式地控制编排顺序能显著提高流程的确定性。4.2 单 Server 接入先跑通最小闭环在搞多 Server 之前我强烈建议先跑通一个 Server 的最小闭环。用官方的 mcp SDK 和 langchain-mcp-adapters接入一个 Server 的代码非常简洁from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from langchain_mcp_adapters.tools import load_mcp_tools server_params StdioServerParameters( commandpython, args[calendar_server.py], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: tools await load_mcp_tools(session) # 把 tools 交给 LangGraph 或 LangChain Agent这一小段代码完成了启动子进程、建立 stdio 连接、MCP 握手、加载工具列表。如果你的环境在这个步骤就报错先别急着上 LangGraph问题几乎一定出在 MCP 连接层。4.3 多 Server 接入MultiServerMCPClient 的正确姿势当你需要同时接多个 Server 时langchain-mcp-adapters 提供了 MultiServerMCPClient它的设计就是为多 Server 场景准备的。用法如下from langchain_mcp_adapters.client import MultiServerMCPClient config { calendar: { transport: stdio, command: python, args: [calendar_server.py], }, files: { transport: stdio, command: python, args: [file_server.py], } } async with MultiServerMCPClient(config) as client: tools client.get_tools() # tools 里已经包含了两个 Server 的工具这里有一个非常贴心的设计当多个 Server 的工具名出现冲突时比如两个 Server 都有叫 search 的工具连接器会自动给工具名前加上 Server 名前缀比如 calendar__search 和 files__search。这样模型就不会因为工具重名而调用错 Server。我实际测试下来这个工具隔离方案在 LangGraph 里非常顺手。给模型看的工具描述里会明确包含“这个工具属于 xxx Server”的信息模型在选择工具时有了更强的上下文依据。4.4 多 Server 调用时的四个关键问题第一个是并发初始化。MultiServerMCPClient 默认会逐个建立连接如果 Server 多了串行初始化耗时很长。我建议并发启动握手用 asyncio.gather 把多个 Server 的连接和初始化分散到并发任务里实测下来能把初始化时间压缩到原来的三分之一。第二个是故障隔离。一个 Server 挂掉时不能拖垮整个 Agent 执行链路。很多人在 LangGraph 节点里直接调用 tools 拿到异常就抛引发整个图终止。合理做法是把异常捕获后转成一条“该 Server 当前不可用”的消息回传给模型让模型决定是否用其他 Server 完成替代方案。第三个是上下文传递。MCP Server 本身是无状态的每次调用都必须传递完整参数跨 Server 的数据不能指望 Server 之间互相通信。在 LangGraph 里一切跨 Server 的数据都通过 State 来传递。比如先从数据库 Server 查出一笔订单再调用通知 Server 去发消息那订单数据必须先存在于 LangGraph 的 State 里然后才能作为参数传给第二个 Server。第四个是超时控制。MCP Client 默认的读取超时可能不太适合业务场景。曾有一个远程 Server 处理大文件时单次工具调用跑了超过默认超时时间Client 直接给模型返回超时错误。我的做法是给 Client 设置一个可配置的读取超时并根据不同 Server 的响应特性区分超时档位。4.5 安全与权限本地和远程都不省心本地 stdio 模式下MCP Server 是子进程继承当前用户的权限。我第一次在 Windows 上启动 Server 时就遇到了“拒绝访问 (os error 5)”后来发现是工作目录和命令路径权限的问题用管理员身份运行不是根本解正确做法是给启动 Server 的调用方配置好目录的读写权限并把 Server 代码放到一个独立的、权限清晰的目录里。远程 MCP Server 如果带了 OAuth 认证报“token exchange failed”这类错误时我排查下来的原因基本上集中在授权服务器的 token endpoint 地址配置错误、client_id/secret 不匹配、系统时钟偏移导致 token 被判定过期。遇到这类问题先把授权链路的配置一项项对一遍大概率能定位到问题源头。5. 实操记录从 zero 搭建一个 LangGraph 多 MCP 的 Agent5.1 环境准备与依赖安装我用的版本组合是 Python 3.11 mcp 1.x langgraph 0.2.x这组版本目前的兼容性验证过比较稳。安装依赖用下面命令pip install mcp[cli] langgraph langchain-openai langchain-mcp-adapters这里要提醒一句mcp 包和 langchain-mcp-adapters 是独立演进的升级 mcp 后适配层经常会报兼容性错误。我建议在项目里锁定主要版本统一升级不要单独升级某一个包。5.2 准备两个实验 Server我会准备两个最简单的 Server 来演示多 Server 调用。第一个是日历查询 Server用 MCP 官方 SDK 写一个最简单的 stdio Server。下面这段代码可以直接存成 calendar_server.py 运行from mcp.server import Server from mcp.server.stdio import stdio_server import mcp.types as types from datetime import datetime, timedelta app Server(calendar-server) app.list_tools() async def list_tools(): return [ types.Tool( nameget_upcoming_events, description获取未来几天内的日程事件days 参数表示未来第几天, inputSchema{ type: object, properties: { days: { type: integer, description: 未来第几天从0开始, } }, required: [days], }, ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name get_upcoming_events: days arguments.get(days, 0) now datetime.now() timedelta(daysdays) return [ types.TextContent( typetext, textf{now.strftime(%Y-%m-%d)}: 有一个产品评审会议14:00在301会议室, ) ] raise ValueError(fUnknown tool: {name}) async def main(): async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream) if __name__ __main__: import asyncio asyncio.run(main())第二个是文件查询 Server可以做简单文件读取。这里有个实际用途Agent 排查问题时需要读取配置或日志文件通过 MCP 暴露文件读取能力比让模型直接拼接路径更安全可控。当然真实业务里的 Server 会比这复杂得多但这两个已经足够验证多 Server 调用流程了。5.3 构建 LangGraph 图把多 Server 工具编排进去先定义 LangGraph 的 State也就是 Agent 的消息状态from typing import Annotated from typing_extensions import TypedDict from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages]然后实现一个调用模型的节点from langchain_openai import ChatOpenAI from langgraph.prebuilt import ToolNode, tools_condition llm ChatOpenAI(modelgpt-4o, temperature0) def call_model(state: AgentState): messages state[messages] response llm_with_tools.invoke(messages) return {messages: [response]}接下来把 MultiServerMCPClient 的工具列表注入并用 StateGraph 组装图async def build_graph(): client MultiServerMCPClient({ calendar: { transport: stdio, command: python, args: [calendar_server.py], }, files: { transport: stdio, command: python, args: [file_server.py], } }) async with client as mcp_client: tools mcp_client.get_tools() llm_with_tools llm.bind_tools(tools) builder StateGraph(AgentState) builder.add_node(agent, call_model) builder.add_node(tools, ToolNode(tools)) builder.add_edge(START, agent) builder.add_conditional_edges(agent, tools_condition, {tools: tools, END: END}) builder.add_edge(tools, agent) graph builder.compile() return graph这段代码的精髓在于 tools_condition 和 ToolNode 的配合模型决定要调用工具时图自动跳转到 tools 节点执行工具工具执行完把结果作为消息放回 State再回到 agent 节点继续推理直到模型不再请求工具图才走到 END。多 Server 的工具全部被放在同一个 ToolNode 里但每个 Server 的名称前缀保证了互不干扰。5.4 运行验证与流式输出运行图时我用流式模式来观察每一步的执行情况async def main(): graph await build_graph() inputs { messages: [ { role: user, content: 帮我看看后天有什么日程然后把结果保存到 notes.txt, } ] } async for event in graph.astream(inputs, stream_modeupdates): for node, value in event.items(): print(f节点: {node}) print(f输出: {value})你会看到图先进入 agent 节点模型决定调用日历 Server 的工具然后图跳到 tools 节点执行工具调用再把结果传回 agent最后模型又决定调用文件 Server 的工具去写文件。每一步的流转都清清楚楚。流式输出在真实项目里非常重要。用户看到的不再是“模型在思考”的圈圈而是“正在查询日历”“正在写文件”这样的实时状态。热词里有人问“使用 MCP 工具流式输出内容到文件”这在 LangGraph 里天然支持把流量输出到事件流里前端按 event 展示每个节点的执行状态即可。6. 常见问题与排查技巧实录6.1 握手失败类问题速查错误现象可能原因解决办法客户端连接后一直超时忘了发 initialized 通知在收到 initialize 响应后补发 notifications/initialized拒绝访问 (os error 5)Server 子进程权限不足给工作目录配置读写权限避免路径在受保护目录下protocolVersion 不兼容客户端声明版本过老或过新用 SDK 的协议版本常量不要硬编码字符串token exchange failedOAuth token endpoint 配置不对检查授权服务器地址、client_id/secret、系统时钟偏差工具列表为空Client capabilities 里没声明对应能力检查 initialize 请求里的 capabilities 声明我实际遇到最头疼的还是“工具列表为空”这个问题。当时 Client 的 capabilities 配置里漏掉了对 Resources 的声明Server 就顺理成章地隐藏了相关工具排查了好久才在探索 Server 调试面板时发现原因。记住一条规律MCP 的权限是双向的Client 声明了什么Server 才会给什么。6.2 工具调用异常模型参数乱传怎么办工具调用的第一杀手是模型生成的参数不符合 JSON Schema。LangGraph 的 ToolNode 会尝试把这些非法参数传给 MCP Server如果 Server 端校验严格直接报错整个 Agent 流程就被打断了。我的处理方式是在工具适配层做一层参数清洗把模型传进来的 kwargs 和工具的 inputSchema 做一次白名单过滤只保留 Schema 里声明过的字段再把参数类型按 Schema 做一次强转。比如 Schema 要求某种类型是整数但模型传了个字符串“3”就手动转成 3。这样做之后因参数格式问题导致的工具调用失败率降低了很多。6.3 LangGraph 多 Server 的上下文串扰问题多 Server 场景里最容易出现的问题是“上下文串扰”模型在前一段对话里用过数据库 Server后一段对话里遇到类似问题直接走了数据库 Server 而不是文档 Server。这其实是工具描述不够清晰导致的。我推荐在每个工具的 description 里显式加上所属 Server 和适用场景比如“来自 calendar Server用于查询日程如果要写文件请用 files Server 的工具”。这样模型在选择路径时就有了更明确的分界。另一个上下文问题发生在 State 里工具返回结果如果太大消息列表会迅速膨胀最后超出模型上下文窗口。我的做法是在工具返回节点里加一个“摘要步骤”把工具返回的长文本先做一个摘要再放到 State 里只保留关键信息既节省 token又避免上下文被无关细节污染。6.4 调试 MCP 的通用技巧官方提供的 MCP Inspector 调试面板非常值得用。它可以在浏览器里可视化地打开一个 MCP Server直接观察握手过程、工具列表、参数格式和调用响应排查协议层问题比看代码日志快得多。stdio 模式下要观察真实流量我给 Server 的入口包一层打印装饰器把收发 JSON 全部打出来再开 MCP 的 DEBUG 日志环境变量就能清楚看到一次握手里每个请求的走向。最后一个建议是把每个 MCP Server 都放到尽量隔离的环境里运行比如用容器管理。不同 Server 的 Python 依赖、Node 版本、工作目录很容易互相影响隔离运行能省掉很多冤枉的排查时间。我个人实际做下来最大的体会是多 Server 调用真正难的往往不是 MCP 协议本身而是如何让 Agent 在不同 Server 之间有序切换。协议和框架只是给你提供了标准接口和编排能力真正决定体验的是你对工具命名、描述、参数约束和错误处理这些细节的把控。最后分享一个小技巧我会给每个 MCP Server 额外暴露一个 health 资源Agent 在开始正式任务之前先批量“探活”一遍所有 Server确认可用再进入业务流程。这样很多中途超时的问题在任务启动那一刻就被拦截了比事后排查要省心得多。