☰
LangGraph多MCP Server集成:从协议握手到工具编排实战
2026/10/12 14:19:25 网站建设 项目流程

MCP 这个词,最近半年在 AI 应用开发圈子里出现的频率越来越高。我一开始接触的时候也觉得它不过是又一层封装,直到有一次在 LangGraph 里真正接入第二个 MCP Server 时才发现,协议握手、工具发现、能力协商这些细节如果不搞清楚,连个朴素的"多服务调用"都可能卡上两三天。这篇东西我想从 MCP 协议最早的那次 initialize 握手讲起,一直聊到怎么在 LangGraph 里挂载多个 Server,把过程中踩过的坑和最终跑通的方案一起整理出来,给正在做 Agent 工具层集成的朋友做个参考。

这篇文章适合两类人:一类是已经把 MCP 单个 Server 的 demo 跑通、但还没想清楚多 Server 怎么编排的开发者;另一类是刚接触 LangGraph,想知道怎么把 MCP 工具作为一种相对标准化的资源接入图编排的人。我不打算堆概念,尽量按照实际调用链路来讲——从握手到工具发现,从封装到图执行,最后是真实环境里最折磨人的几个问题。

1. MCP为什么忽然成了Agent工具层绕不开的环节

1.1 它解决的是“每个模型都要重新接一轮工具”的重复劳动

在 MCP 出现之前,想让一个大模型调用外部服务,通常要自己写一套工具封装:定义参数格式、处理鉴权、约定错误码、再手动把工具描述塞进提示词里。换一个模型,提示词格式变了,工具描述的写法也得跟着变;换一个服务,又要重新走一遍协议设计。这种重复劳动在单工具场景下还能忍,一旦 Agent 要同时面对订单查询、库存检查、知识库检索这类多个后端服务,光协议适配就能占掉大半开发时间。

MCP(Model Context Protocol)做的事情,说直白点就是把这些东西标准化成一个"模型与工具之间的统一会话格式"。服务端只需要实现一套协议接口,客户端统一用同样的消息格式去请求;模型侧看到的是统一的工具视图,参数说明走 JSON Schema,调用方式走统一的请求响应。这样一来,工具从"专线对接"变成了"插拔式资源",这也是 LangGraph 这类编排框架愿意优先支持它的原因。

1.2 单Server演示容易,多Server才是真实世界的常态

我见过不少项目演示时只挂了一个 MCP Server,把某个数据库查询包进去就跑通了。但真实业务很少只有一个数据源。一个稍完整的 Agent 可能要同时触达订单系统、商品库存、物流状态、内部知识库,甚至还有外部搜索服务。把这些工具分别暴露成多个 MCP Server,让 LangGraph 里的 Agent 根据用户问题自动选择合适的工具,是一种很自然的架构演进。

这里有一个容易忽略的点:多个 Server 意味着多次连接、多套能力协商、多份工具列表,而 LangGraph 的 ToolNode 本质上只认"一组工具",不会自动帮你区分哪个工具来自哪个 Server。所以多 Server 的难点并不在协议本身,而在如何把多套连接和工具列表聚合成图里可用的一份资源。这个聚合过程和编排策略,是这篇文章后续的核心内容。

2. 协议握手不只是打个招呼:initialize、能力协商与工具发现

2.1 initialize请求里到底装了什么

MCP 的会话建立在 JSON-RPC 2.0 消息之上。客户端与 Server 建立传输层连接——常见的有 stdio 和 SSE 两种——之后的第一条消息就是 initialize 请求。这个请求不是简单报个到,它要同时携带三样东西:协议版本号、客户端标识、客户端能力声明。

我的经验是,协议版本号(protocolVersion)最容易被人忽视。SDK 有自带的版本常量,但如果客户端和服务端版本不匹配,通常不会直接报错,而是进入版本协商流程:服务端会在响应里返回它支持的版本,客户端需要判断是否继续。我踩过一次坑,客户端 SDK 升级后自动带上了新版本号,而 Server 是旧版本实现的,结果某些高级工具调用行为不一致,报错还很模糊。

握手请求的服务端响应同样包含三个部分:Server 标识、能力声明、以及一段可选的服务端说明。这个说明信息有时候会被忽略,但它在调试时很有价值,某些 Server 会把加载状态、特殊配置提示放在这里。

2.2 capabilities协商决定你能否安全调用远端工具

initialize 阶段最重要的产出是能力协商结果。比如 Server 是否支持资源订阅,是否支持工具调用,是否允许客户端发起采样请求,这些能力都会明确列在 capabilities 字段里。

实践中最关键的是搞清楚"采样"这个能力的语义。MCP 描述了一个角色反转场景:当客户端执行服务端提供的某个工具时,服务端反过来可以请求客户端调用一次大模型补全,用于生成下一步参数。这个设计很巧妙,但也意味着安全边界发生了变化。我建议在 LangGraph 集成场景里默认关闭客户端侧采样能力支持,除非你明确知道自己在做什么,否则会带来不受控的模型调用开销。

能力协商还有一个实际用途:告诉客户端能不能用日志通知之类的高级特性。如果 Server 没声明日志能力,你发日志订阅请求也没用。所以我在封装客户端时,会把 capabilities 解析结果暴露成只读属性,后面做功能开关判断都用它。

2.3 tools/list是握手之后最关键的环节

握手完成之后,客户端会发 tools/list 请求获取工具列表。这是整个 MCP 会话里对 Agent 最核心的一次交互,因为它决定了模型的工具视图。返回结果里每个工具有三个字段:name、description、inputSchema。description 和 inputSchema 的质量,直接影响模型选工具的正确率。

我给 MCP Server 写工具描述有个简单标准:description 里必须说清楚这个工具是干什么的、输入的边界是什么、什么情况下不应该用。比如一个查天气的工具,如果 description 只写"get_weather",模型在涉及"是否需要带伞"这类意图时就很容易选错。反过来,inputSchema 里对必填参数和可选参数的约束越明确,模型生成的参数就越不容易跑偏。

tools/list 支持分页,如果 Server 工具很多会返回 nextCursor,需要客户端循环拉取。这个细节在我第一次写客户端时也漏了,后来工具数量超过几十个才发现使用 cursor 翻页的必要性。

3. LangGraph接入多Server:两种路线与我的选型理由

3.1 客户端聚合和网关聚合各有什么优劣

多 Server 接入本身的架构选择,通常先想到两条路线。

一条是客户端聚合,也就是在 LangGraph 进程内直接创建多个 MCP 客户端连接,把它们各自的工具列表拉回来,合并成一个统一的工具集传给图里的 ToolNode。另一条是网关聚合,做法是引入一个 MCP 聚合网关,每个上游服务依然是独立 Server,但网关对外暴露成一个统一的"超级 Server",LangGraph 只连接网关这一条通道。

两条路线的差异很明显。客户端聚合胜在实现简单、链路短,每个 Server 的连接生命周期由 Agent 进程直接管理,排查问题直观;但代价是 LangGraph 进程和服务端之间会有多条长连接,连接数量一多,资源开销和故障面也会成倍增加。网关聚合则刚好相反,下游客户端只维护一条连接,服务发现和鉴权都集中在网关一层,对于服务数量很多、团队协作复杂的环境更合适,但它自己成为了一个必须保证高可用的新组件,而且协议转发过程中若实现不严谨,可能出现工具参数透传失真。

3.2 为什么我最终选择在LangGraph侧做统一聚合

我实际的项目规模是两到三个 MCP Server,它们分别对接内部数据查询服务和外部检索服务。这个场景下引入网关属于过度设计——又多了一个部署单元和潜在故障点,维护成本远大于收益。最终我选择在客户端做聚合,把所有 Server 的工具列表合并后交给 LangGraph 去调度。

这样做还有一个附带好处:我可以在聚合层对工具做"重命名"和"过滤",从源头上规避下一节要讲的工具命名冲突。LangGraph 侧只要拿到一个规范化的工具列表,后面编排逻辑就保持得很干净。如果你的服务数量超过五个,团队协作也需要统一入口,我建议再认真评估网关方案,不要照搬我这套做法。

4. 手把手把两个MCP Server接进LangGraph

4.1 环境准备:依赖版本选对才能少踩坑

先列一下我跑通这套方案用到的关键依赖版本,它们之间是兼容的:

  • Python 3.11
  • mcp 客户端库(用 stdio 传输的 async 版本)
  • langgraph(0.2.x 以上)
  • langchain-core(用于 Tool 类型)

一个容易被忽视的细节是,MCP 的客户端依赖在异步环境下的行为差异很大。建议从一开始就写 async 客户端,因为 LangGraph 的 ToolNode 天然支持 async 工具调用,后续做并行多个 Server 请求会更顺手。

4.2 把MCP客户端封装成LangGraph工具

核心是把一个 MCP 连接封装成语义上独立的工具集合。封装时要考虑两点:工具名的唯一性,以及调用函数与 MCP Server 的连接绑定关系。我在聚合时给每个 Server 的工具名加了前缀,用 Server 名称加下划线开头作为命名空间,这样两个 Server 即使暴露出同名工具也能在 LangGraph 中稳定区分。

class McpClientWrapper: def __init__(self, server_name: str, session: ClientSession): self.server_name = server_name self.session = session async def list_tools(self) -> list[dict]: result = await self.session.list_tools() tools = [] for item in result.tools: tools.append({ "name": f"{self.server_name}_{item.name}", "description": item.description, "inputSchema": item.inputSchema, }) return tools async def call_tool(self, mapped_name: str, arguments: dict) -> str: raw_name = mapped_name[len(self.server_name) + 1:] result = await self.session.call_tool(raw_name, arguments) return _format_result(result)

这里我特别说一下 _format_result 的处理。MCP 工具返回的 content 是列表结构,每一项可能是 text 文本,也可能是 image 或 resource 类型。我给 LangGraph 用的工具统一转成纯文本,把非文本类型的资源用 JSON 序列化后塞回去,保证模型拿到的是一个可读的字符串。直接返回原始结构会让模型解析起来很吃力。

4.3 多Server编排与工具调用的完整结构

有了封装,多 Server 的接入逻辑就变成初始化连接池,然后合并工具列表,再构建图。

class MCPManager: def __init__(self, server_configs: dict, connection_factory): self.server_configs = server_configs self.connection_factory = connection_factory # 根据配置建立 ClientSession async def start(self): self.sessions = {} for name, config in self.server_configs.items(): session = await self.connection_factory(config) await session.initialize() self.sessions[name] = session tool_groups = await asyncio.gather( *(McpClientWrapper(name, s).list_tools() for name, s in self.sessions.items()) ) self.all_tools = [tool for group in tool_groups for tool in group] async def call_tool(self, mapped_name: str, arguments: dict): server_name = mapped_name.split("_", 1)[0] wrapper = McpClientWrapper(server_name, self.sessions[server_name]) return await wrapper.call_tool(mapped_name, arguments)

这里把" Session 生命周期管理"和"工具注册"解耦了。LangGraph 图构建时,我把 all_tools 交给 ToolNode,同时构建一个简单的映射函数,让 ToolNode 执行工具时找到正确的 Server 会话。多 Server 的并行初始化用 asyncio.gather 来完成,比逐个启动快不少。

下面是一个 StateGraph 的最小示例,它说明了一个 Agent 如何根据用户问题自动选择来自不同 Server 的工具:

from langgraph.graph import StateGraph, MessageGraph, END from langgraph.prebuilt import ToolNode, tools_condition from typing import TypedDict, Annotated class AgentState(TypedDict): messages: Annotated[list, "messages"] graph_builder = StateGraph(AgentState) tool_node = ToolNode(mcp_manager.all_tools, handle_tool_errors=True) graph_builder.add_node("tools", tool_node) graph_builder.add_node("agent", call_model_node) graph_builder.add_conditional_edges("agent", tools_condition) graph_builder.add_edge("tools", "agent") graph_builder.set_entry_point("agent") graph = graph_builder.compile()

call_model_node 内部做的事情,是把当前对话历史和工具列表拼成消息,让模型决定调用哪个工具;tools_condition 会检查模型输出里有没有 tool_calls,有就跳到 tools 节点执行,没有就走到 END。框架层面的这一套大家应该比较熟了,重点在于这里传给 ToolNode 的工具是来自不同 MCP Server 的混合集,因此工具名必须唯一,工具名映射函数必须绑定到正确的 Server 会话。

4.4 关于图编排中消息格式的提示

一个让我调试了挺久的细节是:MCP 工具调用结果返回给模型时,必须以 ToolMessage 的形式回填到对话历史里。ToolNode 默认会做这个转换,但它要求工具返回值是字符串或可序列化对象。所以我强烈建议在封装层就统一把 MCP 返回的 content 结构转成字符串,避免图执行中途出现格式异常。

5. 跑通之后才遇到的三个坑:工具冲突、超时与重连

5.1 同名工具被后注册的Server覆盖

这是多 Server 接入中最容易碰到的坑。两个 Server 暴露了同名工具,比如都叫 query,如果直接在聚合列表里 append,LangGraph 构建工具表时后注册的会覆盖先注册的,而模型看到的工具描述还是两份——于是出现一种很诡异的现象:模型明明按描述选择了某个 Server 的工具,实际执行时却被另一个 Server 的函数接管。

我的解决方案就是前面提到的命名空间前缀。在一个内部项目里,业务侧希望工具名保持简洁,我就改在 description 开头加一行"此工具来自某某服务",匹配实际函数时仍走前缀映射。这样做既保住了模型侧的可读性,也避免了底层调用冲突。两种做法都值得一试,关键是不能只改名不复核 description,否则模型还是会选错。

5.2 长耗时任务把整个Agent卡死的处理

MCP 的调用默认没有超时语义,如果某个 Server 的工具执行了一个耗时的外部请求,图会一直等着。这个问题的麻烦之处在于它不像普通 HTTP 请求那样有明确的超时配置位置,超时要由客户端自己去控制。

我的处理方式是在封装 call_tool 时用 asyncio.wait_for 包一层,并给不同 Server 设置不同的超时阈值。比如内部查询快速失败设为 10 秒,外部检索可以宽松到 60 秒。同时把超时报错文本改写成"工具调用超时,请提醒用户稍后重试",让模型能够把这个信息组织到最终回复里,而不是让 Agent 直接中断。

这里有个取舍:超时定太短,长任务必然失败;定太长,Agent 的响应延迟不可接受。比较实用的思路是区分工具类型给阈值,而不是一刀切。我还尝试过把长任务拆成"发起+轮询"两个工具,把耗时从单次调用里挪出去,效果也不错,只是需要 Server 端配合提供任务状态查询接口。

5.3 连接生命周期:长连接断开后的恢复策略

MCP 会话本质上是长连接,无论是 stdio 子进程还是 SSE 流,Server 重启或网络波动都会让连接失效。这时候 LangGraph 的图本身还不知道,工具调用会突然冒出连接类异常。

我的做法是给 MCPManager 增加一个连接检测和重建机制。每次 call_tool 前先检测会话状态,发现不可用就标记该 Server 为"降级",并把它的工具从当前工具列表里临时过滤掉,同时把一条系统提示注入模型,说明某个服务暂时不可用。当重建成功后再把工具列表恢复。这个降级流程避免了一次服务端故障导致整个 Agent 不可用的情况。

5.4 调试建议:先用纯客户端验证握手,再接LangGraph

最后再说一个我特别想分享的调试经验。不要一上来就把 MCP Server 接进 LangGraph,先用简单的异步脚本直连 Server,手动执行 initialize、tools/list、tools/call 三个步骤,确认 Server 本身的行为符合预期。这一步能过滤掉大量"其实是协议层问题"的干扰。

等直连验证通过后,再把它包成 LangGraph 工具。我在实际项目里试过一次绕开这个过程直接接图,结果区分"图编排问题"和"协议问题"花了一整天,而先做直连验证之后,同样的集成只花了不到两小时。

依赖版本上,我建议把 mcp 客户端 SDK 的版本锁死。这个项目迭代很快,版本升级带来的协议行为变化不明显,但排查起来非常费时间。

拿我自己的体会来说,MCP 多 Server 接入这种事,协议复杂度远低于业务复杂度。你真正要设计的不是传输层,而是工具命名空间、超时策略和连接降级这些"工程性问题"。先把这几件事想清楚,再回头接 LangGraph,会发现整个过程比预期顺利得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询