首页 / 资讯中心 / 文章详情

LangGraph接入MCP多Server:协议握手、命名冲突与连接管理实战

LangGraph接入MCP多Server:协议握手、命名冲突与连接管理实战 ★ FEATURED ARTICLE
MCP 这一波来得比很多技术都快。我从去年开始把 MCP 接进 LangGraph 做多 Server 调用最初最深的印象就是协议文档不算长但真要在一个 Agent 工程里跑顺协议握手、capabilities 声明、鉴权方式、连接生命周期每一个环节都有文档里不会写清楚的坑。这篇分享适合两类人一是刚把 MCP 协议文档翻过一遍、想知道在 LangGraph 里到底怎么落地的开发者二是已经在用单 Server正准备接第二个、第三个 Server想避开连接管理、工具名冲突、凭据配置这些实际问题的人。我会从协议握手讲起最后给出一条从单 Server 到多 Server 的完整调测链路。1. 为什么所有 MCP 集成都要从协议握手说起1.1 LLM 工具调用体系的现状协议出现的必然性在 MCP 出现之前LLM 工具调用基本是各家各话。OpenAI 有 function callingLangChain 有 Tool 抽象每个框架自己定义 schema、自己做路由、自己管参数的 json 序列化。如果你在业务里只接一个内部工具这套东西完全够用。可一旦你开始接第三方系统——比如 GitHub、数据库、监控平台问题就来了每个系统的接口风格不一样认证方式不一样工具列表还是静态写死的想动态加一个能力要么改代码重新发布要么做个复杂的配置系统。MCP 解决的核心问题是把工具发现、工具描述、工具调用、能力声明全部标准化成一套基于 JSON-RPC 2.0 的协议。MCP Server 不再是一堆文档里描述的 REST 端点而是一个能主动向你报名我会什么、每个工具长什么样的服务。客户端比如 LangGraph Agent只需要按协议去握手、去问、去调用不需要预先为每个系统写死实现。这里有一个很多人忽略的点MCP 的思路跟传统 API 网关完全不同。传统 API 是我先知道接口定义然后写代码去调它MCP 是我先连接你你再告诉我你有哪些能力。所以协议握手不是可有可无的形式主义它是整个动态发现机制的第一环。1.2 MCP 与函数调用、API 插件的区别在哪有人会问我直接在 LangGraph 里定义几个 Python 函数做成 Tool不是更简单吗为什么要多引入一层 MCP我的理解是MCP 的价值不在单机单应用而在跨系统、跨团队、跨语言的标准化。从协作面看一个 MCP Server 可以被 LangGraph、Dify、IDE 插件、Codex 这类完全不同的客户端共用。你写好的数据库工具别人在另一个框架里直接连过来用不用重新实现。从动态性看服务器的工具列表是运行时可变的。server 端更新了工具客户端下一次 list_tools 就能感知不需要换版本。从隔离性看工具逻辑跑在独立进程或独立服务里崩了不会拖垮 Agent 主进程语言也可以随便选Node.js 写的 server 照样被 Python 客户端调用。但我也要说句公道话MCP 不是银弹。如果你的工具只有三五个、调用链固定、性能敏感直接用 Python 函数走 ToolNode 反而省事。MCP 引入的是进程管理、传输协议、鉴权这一整套成本。后面我会专门讲什么时候该上多 Server。1.3 握手机制解决的核心矛盾版本协商与能力声明握手initialize在整个协议里承担两件事版本协商和能力声明。版本协商很好理解。MCP 从 2024-11-05 一路更新到 2025-03-26、2025-06-18每次更新都可能增减行为。客户端和服务端支持的范围不一致时通过 initialize 请求里的protocolVersion字段服务端选择一个双方都接受的版本返回后续消息都按这个版本来。能力声明则是握手真正有信息量的部分。客户端要告诉服务端我支持 roots可以给 server 开放本地目录视图、支持 sampling我可以让 server 反过来请求模型补全。服务端要告诉客户端我提供 tools、resources、prompts 里的哪些能力。后续客户端要不要展示某个 UI、要不要尝试调用某类方法都取决于这份声明。可以类比一下握手像两个系统初次见面互相出示证件并且明确我在这次协作里只负责 A、B、C不管 D。没有这一步就强行调用协议语义是不成立的。我在实际调测中见过直接跳过 initialize 就去 list_tools 的客户端结果服务端要么挂起要么返回空列表——这就是把握手的顺序约束不当事的典型结果。2. 一次 MCP 握手到底发生了什么报文级拆解2.1 initialize 请求与响应只交换元信息不干实事一次标准的 initialize 长这样。客户端发{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: { roots: {listChanged: true}, sampling: {} }, clientInfo: {name: my-langgraph-agent, version: 0.1.0} } }服务端返回{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-03-26, capabilities: { tools: {listChanged: true}, resources: {subscribe: true}, prompts: {} }, serverInfo: {name: mcp-server-sqlite, version: 0.6.2} } }注意握手阶段只交换元信息不包含任何实际数据操作。很多第一次读协议的人会把 initialize 当登录接口看待在 params 里塞 token 什么的——不要这么做鉴权是传输层的事后面第 4 节会细讲。服务端在 initialize 阶段也基本不会暴露业务数据它只是告诉你它准备好了、它支持到什么程度。2.2 capabilities 里的三个主角tools、resources、promptscapabilities 是这份响应里最值得读懂的部分它决定你后面能用哪些方法。tools最常用对应tools/list和tools/call。这是 Agent 直接拿来给 LLM 用的函数集合。listChanged: true表示工具列表可能会动态变化客户端可以监听notifications/tools/list_changed。resources对应resources/list和resources/read提供的是结构化数据或文件内容适合让模型读取上下文。比如让 MCP Server 暴露某个配置文件的实时内容。prompts对应prompts/get服务端预置一些提示词模板客户端拿到后可以结合用户输入直接使用。我刚开始接多 Server 时只看tools完全忽略resources结果浪费了一个很大的优化空间很多重复性的上下文比如当前项目的代码规范、数据库表结构说明完全可以放在resources里按需读取而不是每个工具请求里反复携带。热词里的MCP Resource 实战本质上就是把这部分能力用起来。2.3 initialized 通知顺序这件事必须较真握手还有一个容易忽略的第二步客户端在收到 initialize 响应后必须主动发一条通知{ jsonrpc: 2.0, method: notifications/initialized, params: {} }这条通知没有 id它不是一个请求服务端不会针对它做响应。它的作用是告诉服务端我已经完成握手初始化你可以开始接收我后续的正式请求了。按协议规范服务端在实际处理中通常要求客户端先发 initialized 通知才会正常响应tools/list、tools/call这类请求。这里有个实操细节如果你用的是官方 SDK 封装好的session.initialize()SDK 会自动帮你发 initialized 通知。但如果你是自己手搓传输层、或者对接某些只实现了部分协议的服务端顺序就非常容易出错——先发tools/list会导致服务端等待 initialized 通知超时甚至直接关闭连接。我调试过的不少假死案例排查到最后都是这一行代码的问题。2.4 传输层stdio 与 Streamable HTTP 的分帧差异握手之后的传输底层取决于你用什么通道连服务端。stdio客户端拉起一个子进程通过标准输入输出交换 JSON-RPC 消息。每条消息采用类似 LSP 的分帧格式先是一组头字段Content-Length: xxx空一行再是 JSON 正文。这种方式的好处是进程隔离适合本机部署的工具LangGraph 里最常见的用法就是npx拉起一个官方 MCP Server。Streamable HTTP面向远程服务。客户端通过 POST 发送请求服务端响应既可以是普通 JSON也可以是 SSE 流式返回另外客户端可以开一条 GET 的 SSE 长连接来接收服务端主动推送比如工具列表变更通知。早期版本里的 HTTPSSE 传输已经在 2025-06 版本中被移出协议新项目直接按 Streamable HTTP 来做。我看到不少人纠结选哪个。我的建议很直接工具跟当前 Agent 跑在同一台机器上优先 stdio省去网络和认证的复杂度工具是远程团队提供的、或者要被多个客户端共享走 Streamable HTTP。这两种传输在langchain-mcp-adapters里配置方式不同后面代码部分会实际演示。2.5 握手之后的核心动作list_tools 与 call_tool握手完成后Agent 与 Server 之间日常就是两个动作// 列出全部工具 {jsonrpc: 2.0, id: 2, method: tools/list, params: {}} // 调用某个工具 {jsonrpc: 2.0, id: 3, method: tools/call, params: { name: read_sqlite_table, arguments: {table: users} }}tools/list返回的是每个工具的 name、description 和 inputSchema。inputSchema 是 JSON Schema 格式LangChain 会把它转成模型可用的参数结构description对 LLM 选择工具至关重要我后面会专门吐槽工具描述质量的问题。tools/call的返回里有个isError字段它才是判断工具有没有执行成功的关键即使工具内部抛异常只要服务端把错误信息打包在content里返回transport 层依然是一次成功的响应。所以封装工具时不要只看 HTTP/进程有没有报错要读result.isError。这块在 LangGraph 里体现为工具返回的字符串内容我会在下面展开。3. LangGraph 接入 MCP Server两条路线与代码拆解3.1 路线一langchain-mcp-adapters 的 MultiServerMCPClient如果直接用 LangChain 生态最省力的方式是langchain-mcp-adapters里的MultiServerMCPClient。它把多个 MCP Server 的连接管理、握手、工具发现封装好了返回的是一个按 server 名分组的工具字典。import os from langchain_mcp_adapters.client import MultiServerMCPClient client MultiServerMCPClient( { github: { transport: stdio, command: npx, args: [-y, modelcontextprotocol/server-github], env: {GITHUB_PERSONAL_ACCESS_TOKEN: os.getenv(GH_TOKEN)}, }, sqlite: { transport: stdio, command: npx, args: [-y, mcp-server-sqlite], }, } ) with client as tools_by_server: # tools_by_server 形如 {github: [BaseTool, ...], sqlite: [BaseTool, ...]} all_tools [tool for tools in tools_by_server.values() for tool in tools]这里有两个关键细节。第一with client as ...不只是语法糖它负责在进入时统一建立所有连接、完成握手退出时统一清理子进程和 session。多 Server 场景下这个生命周期管理省了我大量心不用自己维护一堆句柄。第二tools_by_server是按 server 分组的这给我处理工具重名留下了空间后面第 4 节专门讲如果直接all_tools一股脑丢给模型重名工具会被后者覆盖。3.2 路线二原生 mcp SDK 手动封装 StructuredTool有些情况下我不想依赖langchain-mcp-adapters比如 Server 是自研的、需要注入额外鉴权逻辑或者工具返回结构需要自定义解析。那就直接用官方mcpPython SDK自己完成握手和封装from contextlib import asynccontextmanager from langchain_core.tools import StructuredTool from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client server_params StdioServerParameters( commandnpx, args[-y, mcp-server-sqlite], ) asynccontextmanager async def load_server_tools(params: StdioServerParameters): async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # 自动完成握手和 initialized 通知 listed await session.list_tools() def make_tool(tool): async def call(**kwargs): result await session.call_tool(tool.name, argumentskwargs) if result.isError: # 服务端主动标记的错误必须作为工具输出传给模型而不是抛异常 return fTool {tool.name} failed: {result.content} texts [item.text for item in result.content if item.type text] return \n.join(texts) return StructuredTool.from_function( nametool.name, descriptiontool.description or , args_schematool.inputSchema, coroutinecall, ) yield [make_tool(t) for t in listed.tools]用的时候async with load_server_tools(server_params) as sqlite_tools: # 拿到的是可被 LangGraph 直接使用的工具列表 pass这条路线的价值是透明。所有握手、工具发现、返回值解析都在你眼皮底下出了问题你能精确知道是哪个环节。我建议对协议还不熟的人先手写一遍这个封装再切到MultiServerMCPClient体验会完全不同。3.3 工具绑定与 ToolNodeStateGraph 里的接线不管走哪条路线最终要把工具接进 LangGraph 的StateGraph。标准做法是用prebuilt.ToolNodefrom langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, START, END from langgraph.prebuilt import ToolNode async def run_agent(tools): tool_node ToolNode(tools) llm ChatOpenAI(modelgpt-4o) async def call_model(state): response await llm.bind_tools(tools).ainvoke(state[messages]) return {messages: [response]} def route_after_model(state): last state[messages][-1] return tools if last.tool_calls else END graph StateGraph(MessagesState) graph.add_node(model, call_model) graph.add_node(tools, tool_node) graph.add_edge(START, model) graph.add_conditional_edges(model, route_after_model, {tools: tools, END: END}) graph.add_edge(tools, model) return graph.compile()ToolNode只是执行器它本身不知道 MCP 的存在。它检查消息里有没有tool_calls有就按名字去工具列表里找并执行结果作为新的 ToolMessage 塞回状态。所以你要保证的是传给bind_tools的工具列表和传给ToolNode的是同一个列表。多 Server 场景下这里就是所有 server 工具汇合的地方。3.4 Agent 运行时的连接生命周期何时建立、何时关闭这个看起来不起眼其实是多 Server 场景最影响稳定性的地方。我的建议是每个 Agent run用户一次完整的提问-回答循环内部建立连接run 结束统一关闭。也就是把MultiServerMCPClient的上下文管理器放在外层整个 run 会话内复用连接。这样避免了每调一次工具就重启一次npx进程的开销——stdio 模式下子进程启动和握手大约是几十到几百毫秒级别但 npx 首次下载可能要十几秒这个开销不能每次都付。但反过来一定不要让连接长期驻留不释放。我见有人把 client 放在全局变量里跑了几百个 run 之后子进程句柄和 SSH 隧道全堆积起来最后进程崩溃。正确姿势是按 run 为单位持有连接结束时用上下文管理器自动关闭。后面的实测章节我会给出完整的三 Server 调用代码范式。4. 多 Server 调用的工程化比协议更麻烦的是连接管理4.1 多 Server 配置别把凭据写进代码多 Server 带来的第一个麻烦是配置膨胀。一个 Server 可能只需要 command、args两个 Server 就开始出现 token、base_url、database_path 这些差异化字段。如果这些散落在代码里每加一个 Server 就要改代码迟早要出事。我的做法是搞一份 YAML 配置文件把连接参数和模型参数分开servers: github: transport: stdio command: npx args: [-y, modelcontextprotocol/server-github] env: { GITHUB_PERSONAL_ACCESS_TOKEN: ${GH_TOKEN} } sqlite: transport: stdio command: npx args: [-y, mcp-server-sqlite] db_path: ${SQLITE_PATH} company_wiki: transport: http url: ${WIKI_MCP_URL} headers: { Authorization: Bearer ${WIKI_TOKEN} }启动时用环境变量展开占位符再传给MultiServerMCPClient。好处是Server 列表可以随环境切换本地、预发、生产敏感信息永远只在环境变量里。我在项目里是把配置加进.env.example明文文件里只放占位符这个习惯救过我很多次。4.2 鉴权stdio 靠 envHTTP 靠 OAuth 与 tokenMCP 的鉴权在协议层没有统一规定完全取决于传输方式。对于 stdio 模式鉴权就是把凭据通过环境变量注入给子进程。上面例子里的GITHUB_PERSONAL_ACCESS_TOKEN就是这么用的。注意不要把它放在 args 里因为子进程的命令行参数会在系统进程列表里可见一不小心就泄漏了环境变量是标准做法。对于 Streamable HTTP 模式协议规范走的是 OAuth 2.0通常是授权码流程加 PKCE。完整实现 OAuth 客户端比较繁琐但流程并不复杂客户端从/.well-known/oauth-authorization-server发现服务器支持的授权端点发起授权、换 token、带 token 访问。有些工具场景也可以简化为 Bearer Token 注入 header这是目前大量自建 Server 实际采用的方式。我踩过一个典型坑HTTP Server 配置了 OAuth但/.well-known/oauth-authorization-server返回的 token 端点 URL 写错了客户端一直报 token exchange failed 之类的错误。这类问题从协议本身看不出任何端倪必须查服务端元数据配置。接第三方 HTTP Server 时第一件事不是写代码而是先把发现端点打开看一眼。4.3 工具名冲突命名空间化的两种策略两个 Server 里各有一个get_status工具这在多 Server 场景几乎必然发生。如果不处理LangGraph 的 ToolNode 会按名字精确查找后注册的工具会把先注册的同名工具覆盖掉模型以为自己在调 A实际执行的是 B——这是最危险的静默错误。我的处理策略是两层。首先利用MultiServerMCPClient返回的按 server 分组结构在合流之前给每个工具的名字加上 server 前缀def namespace_tools(tools_by_server, separator__): namespaced [] for server_name, tools in tools_by_server.items(): for tool in tools: namespaced.append( tool.copy(update{name: f{server_name}{separator}{tool.name}}) ) return namespaced其次在description开头补一句该工具来自 xxx server用于……让模型在语义层面也把两者区分开。命名空间是给运行时兜底的描述是给模型决策用的两个都得做。4.4 超时与首包延迟npx 下载、连接池与重试多 Server 接进来之后最影响体感的是延迟而延迟往往不是工具本身慢而是连接阶段慢。stdio 模式下首包延迟的头号元凶是npx首次拉包。npx -y modelcontextprotocol/server-github如果本机没有缓存要现下现装10 到 30 秒都有可能。这会让用户体验变成我的 Agent 卡死了。我的解决办法有三种按优先级用提前预热部署时先跑一次每个 Server把 npm 缓存打热。全局安装替代 npxnpm install -g后直接用可执行文件路径跳过每次 npx 解析。调用超时兜底给session.call_tool和 LLM 调用都设置显式超时而不是无限等待。import asyncio async def call_tool_with_timeout(session, name, arguments, timeout60): async with asyncio.timeout(timeout): return await session.call_tool(name, argumentsarguments)重试策略分两类连接阶段失败可以重试 2 到 3 次因为多半是 npx 拉包或握手抖动工具执行阶段失败不要盲目重试尤其是写操作重试可能导致重复写入你要先让模型判断错误信息再决定下一步。4.5 进程清理与 session 生命周期多 Server 意味着同时管理多个子进程或多个 HTTP 连接池。Linux 下常见的问题是僵尸进程LangGraph 进程异常退出但npx拉起的 Node 子进程还挂着。stdio 模式下子进程的 stderr 如果不消费缓冲区满了还会把进程写死。我的兜底策略很简单无论 Agent 怎么跑核心的 client 使用始终套在asynccontextmanager里退出路径统一走上下文清理。如果某个 Server 反复崩溃我会额外给它的连接包一层监控连续失败超过阈值就把它从工具列表里摘掉而不是让崩溃拖垮整个 Agent。工具是能力不是依赖——这个心态在多 Server 架构里很重要。5. 单 Server 到三 Server一次真实调测记录5.1 场景GitHub、SQLite、本地知识库三个 Server 并存我拿一个真实做过的场景来走一遍完整链路一个 Agent 需要同时处理查 GitHub 仓库信息、读本地 SQLite 业务表、检索本地知识库 markdown 文件三类任务。前两个用官方现成 Server第三个是我自己写的 60 行 stdio Server目的就是验证自研 Server 能不能和官方 Server 共存。三个 Server 的身份差异很大GitHub 是远程 API 的包装SQLite 是文件数据库操作知识库 Server 是纯本地进程。它们的工具集合大小、延迟特征、崩溃脆性完全不同这正好暴露多 Server 调用的全部问题。5.2 逐步调测每个阶段改了什么第一轮只接 SQLite 单 Server。跑通read_sqlite_table和write_sqlite_table确认 LangGraph 的 model - tools - model 循环正常。这一步主要排除基础接线问题。第二轮加入 GitHub Server。立刻遇到两个情况一是 GitHub Server 官方工具名里包含get_、search_这类通用词和 SQLite 工具表面上看不出冲突但两个 server 的list相关操作语义完全不同模型容易选错二是 GitHub 的 access token 第一次忘了加envserver 起得来但工具全部报权限错误。这轮让我确定必须先做命名空间前缀再合流进工具列表。第三轮加入自研知识库 Server。我在这个 Server 里故意实现了resources/list让模型的系统提示通过 Resource 读取某个目录下文档索引再通过工具搜索具体内容。实践结论是resources适合把高频上下文前置加载能显著减少工具调用次数。三个阶段里我都在看同一组指标握手耗时、tools/list 返回的工具数、单次 run 内调用工具次数、工具总执行时长。数据贴在下面。5.3 工具数量膨胀对上下文的实际影响三个 Server 叠起来工具数从 3 个涨到 30 多个这是多 Server 最容易忽视的成本——不是连接成本而是 prompt 成本。每个工具的 name、description、inputSchema 都要序列化进给 LLM 的消息里。我实际测量的粗粒度结果是30 个中等复杂度的工具 schema 大约能占到 1.5 万到 2.5 万 token具体取决于 description 写得多详尽。如果工具每天服务端还塞了一堆历史遗留的废弃工具模型光读 schema 就读到手软决策质量反而下降。我给出的解法是按需装载不把所有 Server 的工具一次性 bind 给模型而是先根据用户请求做一次粗路由只装载相关 Server 的工具子集。async def run_agent_for_query(query: str, tools_by_server: dict): if github in query.lower() or repo in query.lower(): selected list(tools_by_server[github]) elif database in query.lower() or sqlite in query.lower(): selected list(tools_by_server[sqlite]) else: selected list(tools_by_server[knowledge_base]) # 再走标准 ToolNode 接线 return await run_agent(selected)这套方案在实测里把单次 run 的 prompt 长度砍掉了大约一半模型选错工具的次数也下降了。简单规则路由在大多数场景够用如果 query 语义复杂你可以让一个小模型先做意图分类再决定装载哪个 Server 的工具思路是一样的。5.4 客户端生态观察MCP 正在变成通用接入层调测过程中我顺带观察到一个趋势MCP 不仅在 Agent 框架里扩散IDE 和低代码平台也在接。比如 IDEA 插件接入 MCP 连数据库或 Oracle、Dify 里挂浏览器类 MCP Server、Codex 接 Figma MCP 做设计稿读取本质上都是同一个协议在不同客户端里复用。这意味着你为 LangGraph 写的 MCP Server几乎不用改就能被其他客户端消费。多 Server 调用体验调顺之后这套工具资产是可以跨平台复用的这是我后来更愿意投资 MCP 的根本原因。最后再分享一个我个人的习惯每个新接入的 Server我会先用一个最小客户端单独跑一遍握手——不经过 LangGraph直接ClientSession握手、list_tools、拿第一个工具手动调一次。这个过程能暴露 90% 的配置问题剩下的才是 LangGraph 接线的问题。多 Server 不是把 all_tools 拼起来就完事它逼迫你把工具当成有生命周期的服务去管理。如果你正在从单 Server 往多 Server 过渡我建议你从这个最小验证开始而不是先写几百行 Agent 编排代码。
阅读完成 · 觉得有帮助?
咨询建站