MCP协议握手到LangGraph多Server调用工程实践 上个月在团队内部做了一次关于 MCP 的技术分享题目就是“从协议握手到 LangGraph 多 Server 调用”。本来以为大家只是来听个概念结果连续被问了两个多小时问题从“protocolVersion 到底怎么协商”一直问到“多个 MCP Server 在 LangGraph 里到底如何共存”全程几乎没有冷场。会后我把这次分享的完整内容整理了出来尤其是协议握手、客户端选型、多 Server 架构这几块把当时现场演示的代码和踩过的坑都放进来希望对正在做 AI Agent 工具集成、或者准备在项目里把多个外部系统接进 LLM 工作流的同学有帮助。我默认你已经知道 MCP 的大致定位——它是一套让 AI 应用与外部工具、数据源、提示词资源进行标准化交互的协议。如果你的认知还停在“MCP 就是给 AI 加工具用的”那这篇分享正好可以帮你补上从协议底层到工程落地的完整拼图。下面我会按“为什么需要它 → 握手怎么发生 → 客户端怎么选 → 如何在 LangGraph 里接入并调度多个 Server → 实战问题排查”的顺序展开内容偏工程实践理论部分只讲够用的深度。1. MCP 协议到底在解决什么问题1.1 工具接入的“乱世”与统一接口在 MCP 出现之前AI 应用接入外部能力基本是靠各家自己造轮子。每个应用都定义一套自己的工具调用格式有的用函数描述 JSON Schema有的用actions有的直接让模型生成代码去执行。这套做法在小范围没问题但一旦你想接入多个外部系统——比如让 Agent 同时读取本地文件、查询业务数据库、调用第三方搜索服务——就会立刻遇到一个尴尬局面每个系统都要单独适配工具描述格式不统一认证方式五花八门维护成本随着系统数量线性膨胀。MCP 做的核心事情是把“AI 应用需要调用外部能力”这件事抽象成了一套统一协议。它规定了客户端和服务端之间用 JSON-RPC 2.0 通信定义了工具、资源、提示词三类核心能力还打通了从“声明能力”到“调用能力”的完整闭环。写一次客户端理论上就能对接任何实现了 MCP 规范的 Server。对于做平台型 AI 应用的团队来说这相当于把“插件接口”从私有格式升级成了公共标准。我在实际项目中最大的体感是过去接一个数据源需要大约两到三天包括写工具描述、处理认证、调试返回值格式。接入 MCP 之后如果对方直接提供 MCP Server最快半小时就能完成接线。协议标准化带来的边际收益非常明显。1.2 三个核心角色和两种主流传输方式MCP 协议里有三个核心角色理解它们对后续手写握手和集成都至关重要Host宿主最终的用户应用比如一个聊天产品、一个 Agent 界面负责承载整个交互流程。Client客户端运行在 Host 内部与特定的 MCP Server 建立连接并维护会话的组件。Server服务端提供具体能力的独立进程或服务暴露工具列表、资源列表等。打个比方Host 像是一家餐厅的运营方Client 是服务生Server 是后厨。客人用户/模型通过服务生点菜服务生拿着菜单去找对应后厨再把菜品端回来。菜单就是 Server 声明的工具/资源清单。传输方式方面最新的 MCP 规范主流有两种传输方式适用场景主要特点stdio本地子进程模式客户端拉起 Server 子进程通过 stdin/stdout 传 JSON-RPC 消息零网络配置适合本地工具和开发调试Streamable HTTP远程服务模式基于 HTTP 传输支持单请求响应和流式场景适合跨机器、跨网络的服务调用这两种方式在握手流程上是一样的只是消息承载的通道不同。我推荐的用法是本地小工具、文件系统访问、开发调试优先用 stdio生产环境对接远程服务、多租户场景优先用 HTTP。后面讲 LangGraph 多 Server 架构时两种方式都会涉及到但核心思路一致。2. 从握手开始MCP 初始化过程深度拆解2.1 握手到底在做什么MCP 的会话建立不是简单地“连上就行”它需要客户端和服务端就版本、能力边界、协议语义达成一致。整个过程分三步客户端发送initialize请求声明自己支持的protocolVersion、客户端能力capabilities、客户端标识clientInfo。服务端返回initialize响应返回自己支持的协议版本、服务端能力capabilities、服务端标识serverInfo。客户端发送notifications/initialized通知告知服务端可以正式开始处理业务请求。很多人容易忽略一个细节initialize响应里的protocolVersion不一定是客户端发来的版本。规范允许服务端选择自己支持的最高版本如果双方版本完全不兼容服务端会直接报错。所以握手时最好先按“双方版本交集”去设计兼容逻辑不要假设服务端一定接受你发的版本。能力的协商同样关键。capabilities里每个字段都是一个“开关”比如服务端声明了tools能力客户端才能去调用tools/list如果服务端没声明resources能力客户端就不应该尝试读取资源。这有点像租房合同里约定“允许养宠物才谈养宠物的事”不能一边没答应另一边就开始干。我踩过一个真实的坑有次对接第三方 MCP Server对方在initialize响应里根本没声明tools能力结果我们的代码不管三七二十一直接发tools/list服务端直接返回 JSON-RPC 错误。后来加上能力检查问题立刻消失。协议里每一个字段都不是摆设。2.2 工具调用的完整闭环握手完成后正常情况下客户端进入操作阶段核心链路是tools/list → 获取工具列表和描述 模型根据用户需求选择工具 client 发送 tools/call → 服务端执行并返回结果 工具结果回传给模型模型继续推理这个链路里tools/list返回的内容包含工具名称、描述、输入 JSON Schema。这些描述会作为提示上下文的一部分送给大模型让模型决定“该调用哪个工具、传什么参数”。所以工具描述写得好不好直接影响模型的工具选择准确度。我见过太多人把工具描述写得像接口文档模型经常选错工具或参数乱传这不是模型笨是描述不够“面向模型”。tools/call请求里需要带name和arguments两个关键字段。服务端执行完毕后返回的结果是一个包含content列表的结构化对象每个 content 项通常由type和text组成。这个结果会原样返回给模型。这里有个很多人忽略的点如果工具返回的是大型 JSON建议在服务端先做精简或结构化转换否则大模型上下文会被无关字段塞满推理质量明显下降。2.3 自己实现一个最小握手客户端在看官方 SDK 之前我强烈建议先手动实现一次握手。只有亲手写过一次 JSON-RPC 消息的发送与解析你才能真正理解 MCP 协议的本质。下面这段代码是我用 Python 标准库实现的 stdio 模式最小握手客户端import json import subprocess import uuid class MinimalMCPClient: def __init__(self, command, argsNone): self.proc subprocess.Popen( [command] (args or []), stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, ) self.request_id 0 def _send_request(self, method, params): self.request_id 1 msg { jsonrpc: 2.0, id: self.request_id, method: method, params: params, } line json.dumps(msg) \n self.proc.stdin.write(line.encode()) self.proc.stdin.flush() return self._read_response() def _read_response(self): line self.proc.stdout.readline() return json.loads(line.decode()) def initialize(self): resp self._send_request(initialize, { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: { name: minimal-client, version: 0.1.0 } }) return resp def get_tools(self): resp self._send_request(tools/list, {}) return resp def call_tool(self, name, arguments): resp self._send_request(tools/call, { name: name, arguments: arguments }) return resp def close(self): self.proc.terminate() self.proc.wait()使用方法很简单启动一个 MCP Server 子进程先调initialize再调get_tools。你会发现整个协议就是“请求-响应”式的 JSON-RPC 消息循环没有任何黑魔法。SDK 不过是帮你把这些消息抽象成了更友好的 API同时处理了流式、错误、生命周期等边界场景。提示手动实现客户端时不要忘了最后发送notifications/initialized通知。我刚开始写时漏了这一步服务端虽然响应了 initialize但后续的 requests 一直被服务端侧拒绝。这个通知不是“可选礼貌”而是状态机转换的必要条件。3. 客户端选型与准备工作3.1 用 SDK 还是手写协议动手实现过最小客户端之后接下来要做的决定是正式生产代码到底用官方 SDK还是继续自己维护我个人强烈建议生产环境用官方 SDK手写客户端只作为学习和辅助调试工具。原因有几个SDK 内部处理了大量协议边界细节比如版本兼容、能力缺失、错误重试、流式响应解析等。自己实现很容易漏掉这些 edge case。SDK 会持续跟进协议演进新版本协议发布后SDK 更新成本远低于自研协议层。官方 SDK 通常自带连接管理器、日志钩子、请求选项等工程化能力这些在自研方案里都要从零写。但从“理解协议”的角度手写一个最小客户端依然值得做。把它放在调试工具里配合抓包或日志输出可以非常直观地看到协议消息的原始结构。我现在的调试工作流是先用自研客户端跑通链路确认协议字段正确再切换到 SDK 做生产集成。3.2 工程化准备认证、超时、日志与生命周期MCP 客户端接入到生产环境时有几个工程化问题一定要提前想清楚认证stdio 模式下认证通常由进程启动方式决定比如本地用户拥有足够权限。HTTP 模式下MCP 支持在 initialize 时通过 additional headers 传递认证令牌很多 Server 也支持 OAuth 2.1 流程。建议把认证信息从代码中抽离到配置文件或密钥管理服务尤其是多 Server 场景下每个 server 的认证方式可能不同。超时控制MCP 调用不一定像本地函数那么快。远程 Server、大型工具比如分析类、代码执行类可能耗时很长。SDK 通常支持请求级超时配置建议根据工具的实际耗时设置差异化超时时间而不是全局一把梭。日志协议层日志是排查问题的利器。我见过很多团队排查 MCP 问题时只看应用层错误完全看不到原始 JSON-RPC 消息非常低效。建议在开发阶段开启协议层 debug 日志记录每次请求的 method、params、响应状态和耗时。这在多 Server 场景下尤其重要因为问题定位往往需要回溯“当时到底给哪个 server 发了什么请求”。生命周期管理尤其是 stdio 模式客户端进程是 Server 子进程的“爸爸”必须负责启动和清理。如果 Agent 应用崩溃而没有正确终止子进程机器上会残留一堆孤儿进程。我在集成时会给每个 client 注册atexit清理钩子确保进程正常退出时子进程也被终止。在 LangGraph 场景里生命周期管理会更复杂因为图可能被多次执行、agent 可能并行运行每个单独的 client 实例都需要可追踪、可回收。这部分我后面会展开讲。4. LangGraph 接入从单 Server 到多 Server4.1 LangGraph 里的工具节点长什么样LangGraph 是一个基于状态图的 Agent 编排框架。核心思维是把 Agent 任务拆成多个节点Node节点之间有边Edge连接通过状态对象在不同节点间传递数据。相比直接的 ReAct 循环LangGraph 的优势是支持复杂的跳转逻辑、人工介入、子图复用等特别适合作业系统。在一个典型的 LangGraph Agent 中你通常会有这样的结构Agent 节点把当前状态和可用工具列表发给大模型请求模型作出“下一步动作”的决策。工具节点执行模型选择的具体工具把结果写回状态。要让 MCP 工具进入 LangGraph Agent最核心的一步是把 MCP Server 声明出的工具列表转换成 LangGraph 注册模型所能理解的“工具描述”格式并绑定到模型上。LangGraph 的 ToolNode 会把工具执行结果返回给 AgentAgent 再次决策形成闭环。4.2 接入第一个 MCP Server我先用一个 MCP Server 的情况来演示基础接入from langchain_core.tools import tool from langgraph.prebuilt import ToolNode from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def create_tools_for_server(server_cmd, server_argsNone): server_params StdioServerParameters( commandserver_cmd, argsserver_args or [], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() converted [] for t in tools.tools: converted.append( { name: t.name, description: t.description or , inputSchema: t.inputSchema, handler: lambda namet.name, argsNone, sesssession: sess.call_tool(name, args), } ) return converted这段代码的核心是从 session 拿到工具列表后为每个工具写一个包装 handler真正执行时调用session.call_tool(name, arguments)。LangGraph 的模型绑定只需要拿到名称、描述、输入 JSON Schema 和执行函数。这里有个关键点转换工具时一定要把 handler 绑定到对应 session。如果你在循环里直接引用循环变量很容易出现“所有工具的 handler 都指向最后一个工具”的经典 Python 坑。我上面的 lambdas 用了默认参数namet.name来夹紧值就是防这个。4.3 多 Server 调用的整体架构设计真正遇到多 Server 场景时最直接的需求是一个 Agent 要能同时调用文件系统和业务数据库可能还要调用远程 API 服务。每个能力都由独立的 MCP Server 提供于是就有了“LangGraph 多 Server 调用”的问题。我的推荐架构是把每个 Server 的连接管理封装成独立客户端实例然后用一个统一的管理器注册、调度它们每个 MCP Server 对应一个ClientSession实例连接参数独立维护。管理器维护一个server_name → session的映射启动时一次性初始化所有会话。从多个 session 中汇总工具列表并对工具名做去重/加前缀处理。实际执行时根据工具名解析出它属于哪个 server再把调用请求分发到对应 session 执行。下面是一个简化版的多 Server 管理器class MCPManager: def __init__(self, server_configs: dict[str, dict]): self.server_configs server_configs self.sessions {} self._stacks [] async def connect_all(self): for name, cfg in self.server_configs.items(): server_params StdioServerParameters( commandcfg[command], argscfg.get(args, []), ) stack AsyncExitStack() read, write await stack.enter_async_context(stdio_client(server_params)) session await stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.sessions[name] session self._stacks.append(stack) async def list_all_tools(self, prefix_mapNone): all_tools [] for name, session in self.sessions.items(): result await session.list_tools() prefix (prefix_map or {}).get(name, ) for t in result.tools: tool_name f{prefix}{t.name} if prefix else t.name all_tools.append({ name: tool_name, server: name, description: t.description or , inputSchema: t.inputSchema, }) return all_tools async def call_tool(self, tool_name, arguments): # 解析工具名找到 server server_name, actual_name self._resolve(tool_name) session self.sessions[server_name] return await session.call_tool(actual_name, arguments) async def close_all(self): for stack in self._stacks: await stack.aclose()这个设计有几点值得注意用AsyncExitStack同时管理 stdio 管道的读写流和 ClientSession确保退出时清理顺序正确。工具汇总只返回“描述信息”和“server 归属”不直接绑定 handler而是在调用时动态转发。这种设计在工具数量多、模型需要频繁更新工具列表时更灵活。调用时做一次“工具名 → server 名”的解析把真正的工具名透传给目标 session。4.4 工具名冲突与命名空间的实战处理多 Server 最常见的问题是工具名冲突。比如两个 Server 都提供search_toolLangGraph 在绑定模型工具时如果同名的工具出现两次模型很容易搞混甚至报错。解决方法很简单直接加前缀模拟命名空间。在每个 server 的工具名前加上服务器名比如filesystem__read_file、database__query。模型看到的是带前缀的名称而真正调用时再剥掉前缀还原成原始工具名。上面代码里的prefix_map参数就是干这个的。建议用双下划线分隔因为 MCP 工具名本身也可能含有下划线双下划线能降低还原时的歧义。注意加前缀后工具描述也要对应调整最好在描述开头写明“This tool is provided by the filesystem server”。模型会更加明确工具的归属避免选错工具。如果你用的是 LangGraph 的强大工具绑定工具名最好保持稳定。模型环境变化时如果服务器启动顺序不同导致前缀不一致会造成工具绑定混乱。建议服务器名固定、前缀策略固定而不是随机生成。4.5 多 Server 的并发与错误隔离多 Server 场景下你还得考虑并发和错误隔离。LangGraph 的 Agent 在推理时可能同时调用多个工具如果模型选择了并行工具调用。这意味着多个 session 会同时被使用sdIO 模式下每个 session 是独立的进程所以并发是天然安全的。但 HTTP 模式下需要注意 session 的线程安全性部分 SDK 要求同一个 session 不能并发发请求这时要么给每个 server 建会话池要么在调用层加锁。错误隔离是我实际感触最深的一点。如果一个 Server 挂了不应该拖垮整个 Agent。我的做法是在call_tool外层包一层异常捕获对每个 server 记录失败次数。当某个 server 连续失败超过阈值时自动把它标记为“不可用”并在后续工具列表里临时隐藏它的工具同时输出一条系统提示让模型感知能力变化。这样即使某个数据源彻底宕机Agent 依然能用其他工具完成部分任务。长任务的超时问题也要单独处理。MCP 规范本身不限制执行时长但 HTTP 传输有网络超时LangGraph 的整体执行也有超时设置。建议给不同工具设置不同的超时配置快工具设 10 秒慢工具设 60 秒甚至更长。在 Manager 里维护一个 per-tool 的超时表执行时用asyncio.wait_for包住 session 调用即可。5. 常见问题与排查技巧实录5.1 握手阶段的问题现象一initialize 响应报错 “Unsupported protocol version”原因客户端发送的 protocolVersion 服务端不支持。排查步骤先看服务端要求的版本号不同版本的协议规范并不一定兼容。调整客户端声明的版本号或降级到双方都支持的版本。如果服务端是自研的检查是否在响应里显式返回了支持的版本集合。现象二initialize 成功后tools/list 返回空数组原因服务端没有在 initialize 时声明tools能力或工具注册逻辑有问题。排查时先用命令行工具手动发送 JSON-RPC 请求确认在纯协议层面能否拿到工具列表。如果能问题就在 SDK 封装层如果不能检查服务端日志大概率是工具注册时抛了异常。现象三stdio 模式下服务端启动即崩溃原因子进程命令路径不对、缺少运行依赖、或者工作目录不对。建议先手动在终端里执行 Server 启动命令看能否正常打印日志。再检查 SDK 是否传了错误的启动参数。在 Windows 上要特别注意命令路径含空格时的引号处理。5.2 调用阶段的问题现象一工具名称冲突导致模型调错这个现象很隐蔽——模型没有崩溃但调用结果明显不是用户想要的。比如两个 server 都有search模型调用的是数据库搜索而不是文件系统搜索。排查时先打印模型最终拿到的那份工具描述列表看有没有重名如果有赶紧加上 4.4 节说的前缀方案。现象二调用超时分两种一是网络连接没问题但工具本身执行时间太长二是 HTTP 传输下有代理或网关超时。前者建议调大 per-tool 超时后者需要检查 Server 端是否支持流式响应MCP 规范里某些 HTTP 模式支持 SSE 流式返回可以有效避免网关超时。现象三stdout 被污染导致 JSON 解析失败stdio 模式下服务端如果有print之类的非协议输出到 stdout就会污染 JSON-RPC 流。调试时经常遇到json.loads报错。解决办法确保服务端所有的日志输出都走 stderr客户端解析行时增加容错跳过不是合法 JSON 开头的行。这个坑非常经典我用自研客户端时第一次就撞上了。5.3 调试三板斧综合我的经验MCP 集成问题排查推荐三步走第一在协议层看数据。不管用官方 SDK 还是自研客户端都先抓一份原始 JSON-RPC 消息的日志。看 initialize 请求和响应、看 tools/list 请求和响应。绝大多数问题在这一层就暴露了。第二最小复现。写一个极短的脚本只做“连接-初始化-列工具-调用第一个工具”四件事。如果最小链路能跑通说明底层协议没问题问题在应用层框架或配置。第三检查生命周期。特别是 stdio 模式下的资源泄漏问题每次测试完检查进程中是否有孤儿进程。我见过本地开发跑一天机器上残留了上百个 MCP 子进程的情况整个电脑直接卡死。6. 一次实战后的心得体会这次分享整理到最后我想说说在真实项目中落地 MCP LangGraph 的一些感受。第一次跑通“一个 Agent 同时调用文件系统和数据库”的场景时我心里最强烈的感受其实是这个架构的复杂度并没有想象中高。早期自己做工具适配时每加一个系统就要在应用层写一大堆胶水代码而 MCP 把这一层抽象出来了。只要一次把 Client 层和工具转换层做扎实后面接再多的 Server成本增加得非常有限。我在实际使用中最想强调的还是那句老话先理解协议再使用框架。别急着上 SDK、上 LangGraph先亲手实现一次协议握手你会对整个体系有完全不同的体感。我后来的所有调试效率和排错思路都是从那次手写客户端开始的。另外多 Server 调用这事方案没有银弹。如果你的 Server 数量少、工具类型单一全局一个大 session 就够了。真正到了五个、十个 Server 的规模再考虑我上面说的 Manager 架构。过早抽象只会给自己添乱。最后分享一个小技巧我会给每个 MCP Server 写一个“冒烟测试”配置里面预设一个最典型的工具调用用例初始化后立即自动执行一边。集成新 Server 时先跑冒烟测试验证协议和服务端行为再注册到 LangGraph。这样大部分配置错误都会在最早期暴露而不是等到 Agent 在真实任务里失败时才被发现。这个习惯替我省下的排查时间比我预想的多得多。