
不想再从零开始调 agent 的工具接入又不想被各家私有协议绑死那你大概率已经绕不开 MCP 这个词了。我把最近一段时间的项目经验整理成这篇分享从最底层的协议握手讲到 LangGraph 里怎么优雅地调度多个 MCP Server把踩过的坑、取舍的思路和可以直接抄的代码都放进来。无论你只是想搞懂 MCP 是什么还是正在折腾 LangGraph 多 Server 调用这篇文章应该都能给你省下不少时间。1. 先搞清楚 MCP 到底解决什么问题MCP 的完整名字是 Model Context Protocol翻译过来就是模型上下文协议。说实话这个名字起得有点劝退但它的思路极其朴素AI 应用要操作真实世界的工具AI 应用和工具之间得有个统一接口MCP 就是定义这个接口的协议标准。它由 Anthropic 在 2024 年底开源说白了就是给 AI Agent 提供一套“插拔工具”的标准方式让不用的工具可以通过同一个协议被 AI 调用。想象一下你的抽屉里堆着各种充电线每买一台新设备就要多一根专属线直到 USB-C 把所有线都收编。MCP 之于 AI Agent就像 USB-C 之于外设它把“我要单独对接某个工具”变成了“我把工具的 Server 启动起来Client 自动发现它能干啥”。这个思路一旦跑通生态扩散速度就非常快。短短几个月我看到的适配就已经覆盖了 IDEVisual Studio、VS Code、JetBrains 全家桶、调试器IDA、x32dbg、x64dbg、游戏引擎UE5.8 的官方大模型 MCP、办公软件、财经终端同花顺、地图服务百度地图 AI、项目管理平台禅道等等。这些原本连接成本很高的工具现在都以 MCP Server 的形式对外暴露能力Agent 或者编辑器装上对应的 Client 配置就能直接干活。从开发者的角度看MCP 真正解决的痛点是“工具接入逻辑的重复劳动”。以前每接一个工具你就要写认证、写接口映射、写错误处理、写参数转换而且每个工具都是私有格式换个项目全部重来。现在只要工具方实现了 MCP Server你这边只需要一个通用 Client连接之后通过 JSON-RPC 就能枚举工具、调用工具、读取资源整个接入过程变成了一种标准化的配置工作。需要提醒的是MCP 不是 Agent 框架它不管你的任务怎么编排、状态怎么维护、多步推理怎么执行。它只负责“连接和调用”这一件事。很多人把 MCP 和 LangChain、LangGraph 混在一起聊其实它们是互补关系MCP 管工具接入LangGraph 管流程编排两者各司其职配合起来才是完整的 Agent 方案。搞清楚这条边界后面理解多 Server 调用才不会被绕晕。2. 协议握手MCP 连接的第一道关卡不管用哪个平台、哪个 SDKMCP 连接建立后的第一件事都是协议握手。这一步没走好后面所有工具调用都是空中楼阁。很多人平时用现成 SDK 没留意过这个过程一旦遇到“连上了但拿不到工具”“版本报错很诡异”这类问题十有八九是对握手机制不熟。2.1 握手流程拆解MCP 的握手是典型的三步走两边通过 JSON-RPC 2.0 交换信息。先由 Client 发起一个initialize请求请求里带着三个关键字段协议版本、客户端信息和客户端能力声明。Server 收到后返回自己的协议版本、服务端信息和服务端能力声明。这一步之后Client 还要再发一个notifications/initialized通知表示“我确认了你的能力咱们正式开始”。注意这个通知是单向通知不是请求不需要 Server 返回结果。只有走完这三个动作Client 才能往 Server 发tools/list、tools/call这类业务请求。为什么要把握手拆成这样核心原因是“能力协商”双方必须明确对方支持到什么程度才能决定后续用哪套语义。举个最简单的例子Client 声明自己支持2025-03-26协议版本Server 只支持2024-11-05如果两边不协商直接干活后面请求的字段格式很可能对不上。每次更新的协议版本核心变化要么是新增能力比如对采样sampling、日志logging的支持要么是消息格式调整。实际的 SDK 一般会帮你做版本协商但你要理解背后的逻辑这就像一个双方见面的开场白“我支持这些你支持哪些”互相对上了才继续往下聊。2.2 传输层选型与版本协商先看版本协商的策略。MCP 目前没有官方的语义化版本规则协议版本就是一个日期字符串。协商原则是“取双方都支持的最高版本”。如果 Client 支持多个版本会把它能接受的版本列表发给 ServerServer 从里面挑一个自己也能接受的返回。如果两边完全没交集连接会在握手阶段直接失败错误信息通常会提示Unsupported protocol version。再来看传输层。MCP 官方定义了三种传输方式stdio通过标准输入输出通道通信Server 以子进程方式由 Client 拉起适合本地开发。它的好处是没有端口、没有 CORS、逻辑简单调试体验好但也意味着 Server 和 Client 必须在一个主机上进程生命周期得由 Client 管理。SSEServer-Sent Events基于 HTTP 的传输方式Server 侧可以主动推送事件给 Client。相比 stdio 它支持跨主机但 SSE 本身是单向流实际实现时经常要配合 HTTP POST 来发送请求整体连接管理比较复杂而且企业级部署里负载均衡支持也不友好。Streamable HTTP在 2025 年版本里被官方列为推荐的现代传输方式。它把请求和响应统一走 HTTP支持流式返回能更好地对接网关、负载均衡和认证体系明显是为生产环境设计的。选型时我给一个很实用的判断标准本地项目、快速验证、单人开发直接用 stdio省心要对接远程服务、需要鉴权和横向扩展就上 Streamable HTTP。SSE 现在更多出现在存量系统里新项目不建议特意去选。2.3 握手中的常见坑握手阶段最容易出问题的地方有三个。第一个是协议版本不一致尤其是你用了新版本 SDK 去连一个还停留在旧版本的私有 Server现象就是握手直接失败或行为莫名其妙。处理方案是锁定 SDK 版本或者让 Server 端做版本兼容优先返回 Client 能接受的版本号。第二个坑是能力的“声明”和“实现”不一致。有的 Server 在initialize响应里声明支持resources但实际resources/list没实现Client 拿到能力列表后会去尝试调用然后就报运行时错误。这种情况只能说写 Server 的时候别一咕脑全声明先只声明真正实现的。第三个坑是时序问题。不少人刚连接完就立刻发tools/list结果拿到的列表不完整或直接超时。因为tools/list要等notifications/initialized发完才允许调用。如果 SDK 封装的时序不对或者你手写协议时漏了这一步就会卡在这里。我建议调试时在 Client 侧把 initialize / initialized 这两个阶段打日志确认走完再进入业务逻辑。3. 单 Server 调用从零跑通最小链路当你理解握手逻辑后把整个链路跑通就只剩代码量的问题。这一节我用 Python 手写一个最小可运行的 Server 和 Client不引入任何高级框架就靠 JSON-RPC 本身目的是让你看到协议层到底发生了什么。3.1 最小 Server 实现MCP Server 的本质就是监听请求、返回 JSON-RPC 响应。下面是使用官方 Python SDK 的轻量实现它只暴露一个get_current_time工具。import asyncio from mcp.server import Server from datetime import datetime app Server(time-server) app.list_tools() async def list_tools(): return [ { name: get_current_time, description: 获取当前服务器时间, inputSchema: { type: object, properties: {}, }, } ] app.call_tool() async def call_tool(name: str, arguments: dict): if name get_current_time: return [{type: text, text: datetime.now().isoformat()}] raise ValueError(f未知工具: {name})这段代码里app.list_tools()是对 Client 枚举工具请求的响应app.call_tool()是真正执行工具逻辑的地方。注意返回格式是“内容块数组”MCP 规定tools/call的响应要带content字段里面每个元素有type和text这是 Client 能识别的基础结构。如果你要返回多段内容就多塞几个块比如一段文本加一段图片链接如果要做流式输出就通过_meta和流式通知来实现。Server 写完要用运输层跑起来。用官方 SDK 时可以选择暴露在 stdio 上python server.py但实际上要让 Client 能拉起它你还得在代码里加一段 listen 逻辑。通常做法是把 Server 对象传给某个传输适配器比如mcp.server.stdio或mcp.server.sse。我这边为了演示直接讲下一节 Client 如何连接 stdio 子进程。3.2 最小 Client 实现作为对照Client 侧要做的就是“发起握手、枚举工具、调用工具”三件事。下面是最小可用的客户端代码直接把手写握手的细节暴露在代码里import asyncio import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): server_params StdioServerParameters( commandpython, args[server.py], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: # 握手 init_result await session.initialize() print(协商后的协议版本:, init_result.protocolVersion) # 枚举工具 tools await session.list_tools() print(可用工具:, [t.name for t in tools.tools]) # 调用工具 result await session.call_tool( get_current_time, {} ) print(调用结果:, result.content[0].text) asyncio.run(main())运行这个脚本你会看到输出里出现三行关键信息协商到的协议版本、Server 暴露的工具名、调用返回的时间文本。到这里一个最小链路就通了。从这一步往后你可以把session.call_tool封装成一个函数塞进你自己的 Agent 工具列表里。你可能想问为什么不直接用现成的 LangChain 集成我的回答是先手写过一遍你会知道大语言模型看到的“工具”是从哪来的也知道那些高级框架替你处理掉了什么后面排错会轻松很多。4. 多 Server 并存的现实挑战单 Server 跑通只是第一步现实项目里几乎不可能只接一个工具。你要接数据库查询、接搜索引擎、接地图服务、接内部 API甚至接网易云音乐或禅道。一旦 Server 数量变多问题就接踵而至。4.1 命名冲突与上下文压力多 Server 最直接的问题是工具命名冲突。举个很实际的例子你的项目里同时挂了用户管理 MCP 和订单管理 MCP结果两个 Server 都暴露了名为get_user的工具。对协议来说这没问题因为各 Server 的命名空间是独立的但对大语言模型来说就是灾难它拿到的是一个get_user列表根本不知道选哪一个。第二个逃不掉的问题是上下文窗口压力。每个工具都有 name、description、inputSchema一份 JSON 动辄几百上千个 token。当你挂 10 个 Server、每个暴露 20 个工具时光工具定义就能吃掉好几千 token。大语言模型的记忆是有限的工具太多它反而会“迷路”表现为选错工具、漏掉必需参数、甚至自己凭空编一个工具名出来。我把这些常见问题整理成一个速查表问题现象根因解决方向工具同名调用到错误 Server各 Server 命名空间隔离给工具名加 Server 前缀上下文膨胀模型选错/漏选工具工具定义过多占据窗口动态裁剪工具列表调度无序先查 A 还是 B 靠运气缺少编排逻辑用 LangGraph 路由节点生命周期失控Server 进程泄漏/重复启动多 Client 各管各的连接统一连接池管理权限边界模糊Agent 误删/误写数据工具权限无分层在 MCP 调用前加策略过滤第三类问题是调度无序假设你让 Agent 查“某地今天的天气并推荐一个附近的餐厅”它可能需要先调地图定位再调天气服务最后调点评服务。如果只是简单把全部工具塞给模型它大概率会乱序处理或者选了 A 的结果去喂给 B。这不是模型笨而是缺少流程约束它没有“图”的概念。4.2 调度与生命周期管理多 Server 的另一个容易忽略的问题是生命周期管理。每个 stdio Server 都是一个子进程如果不统一管理就会出现“一个 Agent 跑三个 Server 进程、退出时没人回收”的情况。更隐蔽的是重复连接同一个工具 Server 被多个模块各连一次每个都维护着一份会话和内存状态资源浪费很明显。还有安全边界问题。MCP 工具默认是“全开放”的一旦 Agent 能调用delete_user这类工具它也可能在错误推理下真执行删除。所以多 Server 场景下必须考虑工具权限分层。内部方案一般是在封装层做一层策略过滤哪些工具对该 Agent 可见哪些工具需要二次确认哪些工具只读。协议本身不提供这套机制得自己在应用层加。多 Server 面临的这些挑战核心结论是你需要一个编排层来“统一持有连接、统一命名空间、统一调度决策”而不是把问题抛给大语言模型让它自求多福。这就是 LangGraph 值得引入的地方。5. LangGraph 与多 Server 调和5.1 LangGraph 到底带来什么LangGraph 是构建有状态多参与方 Agent 的框架。它的核心抽象是“图”节点是处理单元比如一个 Call LLM 节点、一个 Execute Tool 节点边是节点之间的流转条件。相比裸写 Agent 循环LangGraph 给出的是一种确定性编排状态在节点之间传递条件边决定下一步走向整个过程可以持久化、可中断、可恢复。用 LangGraph 处理多 MCP Server本质是把“我应该调用哪个工具”和“调用完怎么继续”做成显式逻辑。我在实际项目中把 MCP Client 池定义在 Agent 里用工具名统一加 Server 前缀的方式避免冲突然后让 LLM 在路由节点里做一个“先选 Server再选工具”的判断。这样多 Server 的调度就变成一个有边界的图而不是一把梭地全量调用。你可能会想直接在自己代码里 while 循环会不会更简单纯 while 循环的问题在于状态管理完全靠人肉多分支、重试、并行都很难扩展。LangGraph 的出现正是为了把这些东西变成标准的图结构尤其是条件路由和状态持久化这是它在多 Server 调用中的最大价值。5.2 在 LangGraph 中封装 MCP Client 池LangGraph 的节点函数签名通常是(state: AgentState) - dict而 MCP 的 Client/Session 是异步且有状态的资源不能直接塞进 state 里被 pickled 序列化。实际方案是把 clients 放在 graph 的 config 或者外部上下文里通过 closure 传给节点。下面是我在实际项目中用过的一个简化版封装。先定义一个 Client 池管理器和适配函数import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client class MCPClientPool: def __init__(self, server_configs: dict): self.server_configs server_configs self.sessions {} async def start(self): for name, cfg in self.server_configs.items(): params StdioServerParameters( commandcfg[command], argscfg[args], ) read, write await stdio_client(params).__aenter__() session await ClientSession(read, write).__aenter__() await session.initialize() self.sessions[name] session async def call(self, server_name: str, tool_name: str, arguments: dict): session self.sessions[server_name] result await session.call_tool(tool_name, arguments) return result.content[0].text async def list_tools(self): all_tools {} for server_name, session in self.sessions.items(): tools await session.list_tools() all_tools[server_name] { t.name: t for t in tools.tools } return all_tools在这个基础之上我需要写一个适配函数把不同 Server 的 MCP 工具包装成 LangGraph 能识别的工具函数。关键点在于工具名的设计统一使用server_name::tool_name这种方式让模型一眼就知道这个工具来自哪个 Server。def build_mcp_tools(pool: MCPClientPool) - list: # 内存缓存工具名 - (server_name, tool_name) all_tools asyncio.run(pool.list_tools()) tools [] for server_name, tools_map in all_tools.items(): for tool_name, tool_meta in tools_map.items(): tool_key f{server_name}::{tool_name} schema { name: tool_key, description: tool_meta.description, parameters: tool_meta.inputSchema, } tools.append({ type: function, function: schema, }) return tools这里我选择让 LangGraph 的ToolNode来执行工具。实际项目中 MCP 的call_tool是异步的而 LangGraph 的工具函数可以是异步的所以可以把 pool 的调用包装成一个 async 函数返回给 LLM 的工具对象就是标准的 OpenAI function calling 格式。 LangGraph 的节点会调度这些工具函数实际调用就是pool.call(server_name, tool_name, arguments)。如果你用的模型是 Anthropic 的 Claude则工具格式反而更简单直接把name、description、input_schema传给模型即可。这里的重点是不要让模型直接面对“MCP”这个概念它只需要看到一堆带前缀的工具名。5.3 多 Server 路由与结果聚合把工具封装好之后能不能在ToolNode里直接用我的经验是能但不够优雅。有时候两个 Server 需要按顺序调用比如先调用位置服务再调用天气服务这两个步骤之间存在数据依赖简单的工具轮询无法保证顺序。所以我会在图里加一个显式的“路由节点”让 LLM 输出意图类型再根据类型走不同的边。这里的核心是在 LangGraph 的StateGraph里定义两个关键节点agent决策和调用 LLM和tools执行工具。在构建图时关键的代码大概是这样的from langgraph.graph import StateGraph, END from langgraph.prebuilt import ToolNode # state 结构需要包含 messages 和执行记录 def rule_based_router(state): last_ai_message state[messages][-1] if not last_ai_message.tool_calls: return finish return call_tools def make_graph(pool: MCPClientPool): tools build_mcp_tools(pool) tool_node ToolNode(tools) def agent(state): # 让 LLM 基于 messages 决定调用哪个 server::tool response llm_with_tools.invoke(state[messages]) return {messages: [response]} graph StateGraph(...) graph.add_node(agent, agent) graph.add_node(tools, tool_node) graph.set_entry_point(agent) graph.add_conditional_edges( agent, rule_based_router, {finish: END, call_tools: tools}, ) graph.add_edge(tools, agent) return graph.compile()这只是一个最小骨架。在实际功能中你还可以在tools节点之后加一个聚合节点专门处理“多个 Server 调用结果如何合并进下一轮上下文”。比如前面说的位置服务 天气服务更需要一个中间状态存下“当前位置”和“当前天气”然后汇总成一条消息交给下一轮决策。这个中间状态在 LangGraph 里就是 state 里的一个字段你可以自定义对象只要它是可序列化的。聚合节点的作用就是“把混乱的返回整理成结构化上下文”。我通常会定义state[context]来存跨节点的共享数据这样每次调用完 Server 后可以把结果里的关键字段抽出来存进去避免下一轮模型还要读原始 JSON。多 Server 编排时还要注意并行性。LangGraph 本身支持并行边在不同 Server 之间没有依赖时可以让它们并行执行而不是串行等待。实际的代价是MCP 的 stdio 子进程天然适合并行它们互不干扰并行简直白赚性能。6. 常见问题与排查技巧实录这一节我把自己踩过比较多坑的问题梳理一下每条都是真实场景排查思路也是实操过的比看文档直接。6.1 握手与连接类问题第一个经典问题mcp.client.stdio.stdio_client连接时报错用的是官方推荐的新版 Streamable HTTP 方式。排查步骤我一般是这么走的先确认协议版本是否和 SDK 匹配。mcpnpm 包和 Python 包的版本差异经常导致版本号对不上可以临时把mcp包都换成同一版本。再看初始化是否完成。Client 的initialize和notifications/initialized是两回事有的 SDK 封装不严格没等通知发完就发业务请求。如果收到Request timed out先怀疑时序。最后看 Server 端的日志。有些 Server 初始化后立即退出但连接还没探活Client 看到的现象是“挂起”。用 stdio 跑 Server 时记得把 stderr 打印出来能快速定位。同类问题还有自定义 Server 的initialize响应里没带capabilities字段有的 Client SDK 会直接崩溃。所以 Server 端能力声明宁可少写也不要漏写字段。第二个常见问题是传输方式选错。本地用 stdio 连/tmp/tool_server.sock时如果 Client 侧没把 stdio 的输入输出配好会得到奇怪的字符错误。排查方法很简单先用命令行直接跑 Server 看看输出再走代码连能更快定位。6.2 工具调用与编排类问题多 Server 项目里最常见的问题是“工具名带了前缀但 LangGraph 不认识”。原因在于ToolNode要求工具函数必须是可调用对象而 MCP Client 返回的工具元数据只是一个 schema。解决办法是在封装时把call_tool包成一个真实的 Python 函数并保证函数的name和 schema 里的name完全一致。第二个高频问题是“工具调用了但结果没流式输出到文件”。热词里有个 “cherrystudio 流式输出内容到文件” 的场景其实就是 MCP 返回的 content 块大多是文本并不保证流式需要服务端在tools/call返回时使用流式通知分片输出。如果你对接的是第三方 Server自己没法定制可以在 Client 侧做缓冲先把结果全部取回来再统一写入文件避免丢失。第三个问题非常隐蔽LLM 拿到了带前缀的工具名后它可能会自作聪明地在参数里加server_name字段而真正的工具函数并不接受这个参数于是报TypeError: call_tool() got an unexpected keyword argument server_name。解决办法是在包装函数签名里明确白名单参数其他地方通过**kwargs吃掉无形参。第四个坑是 LangGraph 的状态序列化问题。我一开始把 client 对象直接放在AgentState的一个字段里编译执行时出现了 pickling 错误。正确做法是动态的 MCP client/session 一律放图外部或 config不要让状态机负责持有时钟对象。状态里只放“Server 名”和“工具调用历史”这些可读数据需要实际连接时通过 pool 索引回去。6.3 安全与上下文管理建议多 Server 场景不能忽略权限。我的经验是不要把工具权限暴露给模型自由发挥在调用层前加一个白名单只读策略。比如某个 Server 暴露了delete_project但当前 Agent 的任务只是查询那你就在封装时过滤掉危险工具不要让它出现在模型的工具列表里。否则模型一旦推理失误就会直接触发不可逆操作。上下文管理也是老生常谈。多 Server 场景下工具定义很容易膨胀但 LangGraph 可以在一个节点里只把“当前需要的几个 Server 的工具”注入给 LLM而不是全量注入。实现方式就是让那个节点去pool.list_tools()然后按需过滤这比把所有定义都塞给模型的指令效果稳定得多。最后聊一下资源回收。我用 LangGraph 的编译图包裹多个 Server 时写过一个async withpool 的上下文管理器在 Agent 结束后自动关闭所有子进程。如果你忘了关闭 stdio 子进程连续跑多个任务时内存肉眼可见地涨排查半天才发现是 MCP 子进程孤儿化。class MCPClientPool: async def aclose(self): for session in self.sessions.values(): await session.__aexit__(None, None, None) self.sessions.clear()在多轮 Agent 任务里记得把这个aclose()挂在 finally 或图终点的清理节点里别心疼那几行代码子进程泄漏最后都会变成最难查的报错。7. 一点个人体会手快的话搭一个能跑的两个 Server LangGraph 编排的小 demo 用不了一个晚上真正花时间的反而是那些不起眼的细节命名冲突、进程回收、工具权限、上下文裁剪。让我总结一条经验先把传输和握手搞明白再上框架。很多人一上来就套 LangGraph 的封装遇到问题就抓瞎。其实协议层的时序和消息格式你都读过一轮了LangGraph 里的报错就基本都能定位到是哪个环节出的问题。另一个建议是多 Server 调用不要过度依赖 LLM 的“自然语言路由”。模型当然可以阅读工具描述然后决定调用谁但在生产环境规则 LLM 混合路由更稳定能用规则判断的先走规则拿不准的再让模型决策。像“先定位再查询天气”这种固定流程直接写死在图里让 LLM 只处理那些真正开放的决策准确率和可维护性都会明显提升。MCP 生态还在快速膨胀今天你看到的各种 IDE、调试器、数据库管理工具都在出 Server过段时间肯定还会有更多。这套标准化工具连接方式的价值不在于某个函数怎么写而在于整个生态的接入成本被大幅拉低了。把协议理解扎实之后以后再接触新工具你只需要写一个配置文件和几行 Client 封装就完事了。这篇文章里的代码都是从实际项目里抽出来的可以直接照着改造也欢迎你结合自己的场景补充思路。