
最近一直在折腾 MCPModel Context Protocol从最开始简单接一个文件读写 Server到后面把数据库、代码检索、浏览器自动化等一堆 Server 全挂进来再到用 LangGraph 把它们组织成多 Agent 协作流程整个过程踩了不少坑也把协议层面的很多细节彻底搞明白了。这篇东西不打算写成教科书就按我实际动手的顺序从协议握手开始一步步讲到 LangGraph 里多 Server 的调用组织方式。如果你正准备把 MCP 用到自己的 Agent 项目里或者已经在用了但遇到各种初始化失败、工具调不到的问题这篇应该能帮你少走很多弯路。1. 先从问题说起MCP 到底在解决什么1.1 为什么会有 MCP 这个东西传统的 LLM 应用要接外部工具最头疼的不是模型本身而是工具接口的碎片化。每个数据源、每个软件都有自己的 API 风格有的走 REST、有的走 WebSocket、有的干脆就是个本地命令行工具。你要在一个 Agent 里同时用上文件系统、SQLite、浏览器控制这三个能力就得写三套完全不同的集成代码每一套都要处理鉴权、重试、错误码维护成本直接起飞。MCP 的出现就是为了把这件事统一起来。它的思路很像 USB 接口——你的电脑MCP Host不需要知道鼠标、键盘、U 盘各自内部怎么工作只要它们都遵守 USB 协议插上就能用。MCP 里的 Server 就是各种外设Client 就是电脑上的 USB 控制器Host 则是 Claude Desktop、IDE、或者你自己写的 Agent 程序。只要一个工具实现了 MCP Server任何支持 MCP 的客户端都能直接接入不用再为某个特定平台写专属适配。1.2 MCP 的三个核心组成部分理解 MCP 的架构抓住三个角色就够了Host最终用户面对的应用程序比如 Claude Desktop、VS Code 插件、CherryStudio、还有我们自己用 LangGraph 搭的 Agent。Host 负责管理多个 Client 的会话。Client嵌入在 Host 里的连接器。一个 Host 可以同时持有多个 Client每个 Client 对一个 Server 维持一条独立连接。Server暴露具体能力的服务。可以是一个本地子进程stdio也可以是一个远程 HTTP 服务。Server 提供三类能力Tools可调用的函数、Resources可读的数据、Prompts可复用的提示词模板。这个分层非常关键。很多人以为 MCP 就是在 Claude Desktop 的配置文件里加几行 JSON其实那只是 Host 的配置入口真正干活的是 Client 和 Server 之间的那套协议交互。你可以在不依赖任何现成 Host 的情况下用官方 SDK 自己写一个 Client 去连 Server这也是后面 LangGraph 集成的基础。1.3 我在项目里选择 MCP 而非自写 API 封装的理由说实话如果只是接一两个工具手写一个tool_call函数也不算难。但一旦工具数量超过五个你就会碰到几个很现实的问题工具发现机制手写方案里工具列表是你写死的新增一个工具要改代码MCP 里Client 在握手后调用tools/list就能拿到 Server 的全部工具清单动态发现、动态加载。参数校验MCP 的 Tool 定义自带 JSON Schema模型侧可以直接按 Schema 生成合法的调用参数比你在代码里手写一堆 if-else 判断参数合法性强太多。多端复用同一个 Server 可以被 Claude Desktop、自研 Agent、IDE 插件同时连接不需要每个端都重新写一遍对接逻辑。传输层解耦本地工具用 stdio 进程通信远程服务用 Streamable HTTPClient 侧的调用代码几乎不用变。这个统一的价值用久了才会真正体会到。我从一个自研的文件搜索工具开始把它包成 MCP Server 之后先是接进了 Claude Desktop 做了些文本整理然后又在 LangGraph 项目里复用同一套 Server 做自动化批处理一个工具两处用代码一点没改。2. 协议握手的完整链路从 initialize 到 tools/list2.1 一条连接的生命周期MCP 的通信底层走的是 JSON-RPC 2.0。这个协议结构非常简单每个请求带一个id、一个method、一个params响应要么是result要么是error。MCP 在这上面定义了自己的一套方法集最核心的是生命周期里的这几个方法阶段消息方向方法名作用初始化Client → Serverinitialize发起协议握手协商版本与能力初始化响应Server → Clientinitialize的result返回 Server 的协议版本与能力声明初始化确认Client → Servernotifications/initialized通知 Server 握手完成开始正常通信工具发现Client → Servertools/list获取 Server 提供的全部工具清单工具调用Client → Servertools/call按名称和参数调用具体工具资源访问Client → Serverresources/list/resources/read列出并读取可访问的资源其中initialize、tools/list、tools/call是请求/响应模式必须有id对应notifications/initialized是通知模式不需要响应。这个区别看着小但设计上很重要——通知就是单向的发出去就不管了而请求必须等待响应超时了要能处理。2.2 initialize 握手里到底协商了什么很多人看日志发现initialize的请求里带了一堆字段不知道每个是干嘛的。我拆开来说{ jsonrpc: 2.0, id: 0, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: { roots: { listChanged: true }, sampling: {} }, clientInfo: { name: my-agent-host, version: 0.1.0 } } }protocolVersion客户端支持的协议版本。2024-11-05 和 2025-03-26 是目前最常见的两个版本2025-03-26 加入了一些结构和接口层面的调整。Server 收到后会检查自己是否支持这个版本如果双方版本不一致Server 会在响应里返回自己支持的版本号。capabilities客户端声明自己支持哪些 MCP 特性。roots表示客户端可以向服务器暴露文件系统根目录信息sampling表示客户端允许服务器发起模型采样请求这个一般不用开。clientInfo标识客户端身份类似 HTTP 里的 User-Agent方便 Server 做兼容判断和日志排查。Server 的响应长这样{ jsonrpc: 2.0, id: 0, result: { protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true } }, serverInfo: { name: my-tools, version: 0.2.0 } } }响应里的capabilities.tools说明这个 Server 支持工具调用listChanged为 true 表示工具列表会动态变化Client 需要监听notifications/tools/list_changed消息。如果 Server 只声明了resources那就只提供资源读取能力没有工具可调。2.3 工具发现与调用的完整逻辑握手完成后Client 会发tools/list请求拿工具清单。每个工具的结构如下{ name: sqlite_query, description: Execute a read-only SQL query against the local database and return results as JSON., inputSchema: { type: object, properties: { query: { type: string, description: The SQL statement to execute } }, required: [query] } }name是工具的唯一标识description是给模型看的函数说明inputSchema是 JSON Schema 格式的参数定义。这一步非常关键因为 LLM 判断该不该调用这个工具、传什么参数完全依赖description和inputSchema的质量。调用时Client 发tools/call参数里带上工具名和模型按 Schema 生成的参数值。Server 执行完返回的执行结果里isError字段用来标记工具执行是否出错——注意这里即使业务上失败了比如 SQL 查询报错协议层面依然是成功响应只把isError设为 true这样模型能继续根据错误信息调整下一次调用。2.4 关于版本协商和兼容性我在实操中发现协议版本不对是握手失败的常见原因。如果你是用的官方 SDK一般都会自动处理版本匹配但如果你自己手写 JSON-RPC 客户端一定要实现版本协商逻辑读取 Server 返回的protocolVersion如果和你支持的不一致应该按 Server 的版本来重新初始化或者明确拒绝。另外notifications/initialized这个确认消息很多人会漏发。服务端一般在收到它之后才会开始处理业务请求如果你只发了initialize就急着调tools/list部分严格实现的 Server 会直接拒绝。这个坑在自研 Server 时特别容易出现我最初就漏了这步排查了很久才发现是少发了一条通知。3. 多 Server 接入的三种组织思路手动、打平、编排当项目里出现两个以上的 MCP Server 时组织方式就变得重要起来。我试过三种方案各有取舍。3.1 方案 A代码里为每个 Server 单独建 Client最直接的办法就是在代码里为每个 Server 创建一个独立的 Client手动管理它们的生命周期client_a await create_mcp_client(server_a) client_b await create_mcp_client(server_b) tools_a await client_a.list_tools() tools_b await client_b.list_tools()这种方式的优点是逻辑清晰每个 Server 的连接独立一个挂了不影响另一个。缺点也很明显——工具调用时你得自己决定这个问题该调哪个 Server模型没有全局视角你很难让它跨 Server 协作完成一个复杂任务。用 LangChain 的话来说工具虽然都在手上但没有放在同一个可被模型感知的能力池里。这个方案适合 Server 数量少、职责边界极其清晰、且任务完全不需要跨工具组合的场景。比如一个 Server 管文件一个 Server 管日志任务是读取日志文件并提取报错那就没必要上编排框架手动分流最简单。3.2 方案 B把多个 Server 的工具全打平到统一 ToolNodeLangGraph 的create_react_agent接受一个tools列表这个列表天然支持跨 Server 拼接。你把每个 MCP Server 的工具全部加载出来然后all_tools tools_a tools_b一次性交给 Agent。这是最省事的方案也是很多人一开始用 LangGraph 接 MCP 的方式。这个方案的好处是模型拥有了全部工具的全局视野它可以自己决定调哪个、要不要连续调多个。缺点在工具数量多了之后会暴露工具列表一长光描述文本就可能占掉几千个 token模型反而更容易选错工具。另一个问题是权限边界变模糊——所有工具对模型一视同仁文件删除工具和文件读取工具的调用门槛是一样的这在生产环境里很危险。如果工具总数控制在十五个以内且没有高权限操作这个方案足够了。我早期做内部自动化脚本时就是这么干的简单直接。3.3 方案 C用 LangGraph 做多 Agent 编排每个 Agent 管自己的 Server工具多、权限分级严、需要多步协作的场景就要上多 Agent 架构。思路是这样的每个 Agent 只绑定自己的 MCP Server 工具集负责一类职责外层一个 Supervisor 或路由器根据用户请求决定把任务派给哪个子 Agent。子 Agent 之间可以互相调用也可以通过共享 State 传递中间结果。这个方案本质上是把工具路由从代码层提升到了模型决策层。每个子 Agent 看到的工具列表都被裁剪过提示词污染和误调用概率都大幅下降同时权限边界也清晰了——文件访问 Agent 永远接触不到数据库工具。代价是架构复杂度上去了。你需要处理 Agent 之间的 State 传递、子 Agent 的结果汇总、以及多轮协作的上下文管理。LangGraph 的核心价值就在这里它把这种多 Agent 图式编排做成了标准化的节点和边不用你从零实现。3.4 三种方案的取舍对比方案适用场景优点风险A. 手动多 ClientServer 少、任务固定结构简单、排错容易模型无法跨 Server 决策扩展性差B. 工具全部打平工具 15 个、无高风险操作模型全局决策实现最快提示词膨胀权限边界模糊C. LangGraph 多 Agent工具多、权限分级、流程复杂工具按职责隔离可编排多轮协作架构复杂需要状态管理与调试经验我从方案 A 一路做到方案 C最大的感受是不要为了架构好看一上来就上多 Agent。工具少时打平就行等真出现工具选择混乱或者权限问题时再迁移到方案 C重构成本其实不高。4. 实战LangGraph 里挂多个 MCP Server 并跑通调用4.1 环境准备我这里用的是 Python 生态核心依赖有三个langgraph图编排框架langchain-mcp-adaptersLangChain 官方出的 MCP 适配包负责把 MCP 工具转成 LangChain 的 Toolmcpmodelcontextprotocol官方 Python SDK还有一个langchain-openai用来接模型你可以换任意兼容 OpenAI 的模型服务。pip install langgraph langchain-mcp-adapters mcp langchain-openai如果你用的是 TypeScript 生态对应的是langchain/mcp-adapters、langchain/langgraph和modelcontextprotocol/sdk逻辑几乎一样。4.2 编写 MCP Client 连接代码先写一个通用的加载函数接收 Server 启动命令和参数返回工具列表import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from langchain_mcp_adapters.tools import load_mcp_tools async def load_tools_from_server(server_name: str, command: str, args: list[str], env: dict | None None): server_params StdioServerParameters(commandcommand, argsargs, envenv) read, write await stdio_client(server_params).__aenter__() session await ClientSession(read, write).__aenter__() await session.initialize() tools await load_mcp_tools(session) return tools注意StdioServerParameters是标准输入输出传输的关键——Server 以子进程方式启动MCP 通过它的 stdin/stdout 传输 JSON-RPC 消息。在 LangChain 的社区实现里有些版本让你在langgraph.json的mcp配置节里直接声明 Server框架会帮你做生命周期管理{ mcp: { servers: { files: { command: python, args: [-m, mcp_server_files], env: {} }, sqlite: { command: python, args: [-m, mcp_server_sqlite], env: {} } } } }但这种自动装配在复杂场景下反而不够灵活——你没法精细控制每个工具的去向。我最终选择了手动加载方案把整个生命周期握在自己手上。4.3 把工具注册进 LangGraph 的 Tool 层load_mcp_tools返回的每一个 Tool 对象本身已经兼容 LangChain 的BaseTool接口可以直接放进create_react_agent的tools参数里。所以整个拼接逻辑非常简单from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent async def build_agent(): file_tools await load_tools_from_server( files, python, [-m, mcp_server_files], ) db_tools await load_tools_from_server( sqlite, python, [-m, mcp_server_sqlite], ) all_tools file_tools db_tools model ChatOpenAI(modelgpt-4o, temperature0) agent create_react_agent(model, all_tools) return agent async def main(): agent await build_agent() result await agent.ainvoke({ messages: [{role: user, content: 帮我在当前目录下创建一个 notes 文件夹然后在里面建一个 meeting_2025.md 文件内容是昨天和周会相关的三条待办事项。}] }) for message in result[messages]: print(message.pretty_repr())这里有个细节值得展开create_react_agent内部是标准的 ReAct 循环——模型决定调用工具 →ToolNode执行工具 → 结果作为新的消息喂回模型 → 模型继续决策或结束。MCP 工具的接入点就在ToolNode这一层。它不关心工具是本地函数还是远程 MCP接口统一是输入参数、返回输出这是 LangGraph 能和 MCP 无缝配合的根本原因。4.4 如果在 LangGraph 里要让 MCP 工具支持流式输出热词里有人提到使用 mcp 工具流式输出内容到文件 cherrystudio这个需求在 LangGraph 里也可以实现。MCP 调用本身支持流式响应比如_meta里带进度信息或者 Server 端多次返回部分结果但 LangGraph 默认把工具调用当成一次性完成。如果你需要工具执行过程中就逐步看到输出需要在自定义工具里做流式回调或者直接不经过 LangGraph单独跑一个 MCP 会话把 Server 返回的流式内容逐段写入文件。我实践中更常用的做法是把流式写出这件事本身封装成一个 MCP 工具Server 端负责分块读写Agent 端只负责发起调用。这样 LangGraph 层面无需感知流式逻辑更干净。4.5 结合子 Agent 的进阶写法如果上面的全局打平方案不适合你LangGraph 里切子 Agent 也很方便。核心是在图上声明两个节点每个节点绑定一个 Agentfrom langgraph.graph import StateGraph, START, END class WorkState(TypedDict): messages: list def make_files_agent(): tools asyncio.run(load_tools_from_server(files, python, [-m, mcp_server_files])) return create_react_agent(model, tools) def make_db_agent(): tools asyncio.run(load_tools_from_server(sqlite, python, [-m, mcp_server_sqlite])) return create_react_agent(model, tools) graph StateGraph(WorkState) graph.add_node(files_agent, make_files_agent()) graph.add_node(db_agent, make_db_agent()) graph.add_edge(START, files_agent) graph.add_edge(file_agent, db_agent) graph.add_edge(db_agent, END)这段代码展示的是最简单的串行编排实际项目里你还会需要条件路由和状态聚合。但核心思想是一致的——每个子 Agent 只持有自己那组 MCP 工具图结构负责控制流向从而实现多个 Server 有序协作而不是各干各的。5. 踩过的坑与排查记录权限、超时、工具膨胀5.1 Windows 下 stdio 传输的拒绝访问 (OSError 5)这是我被折磨最久的一个坑。在 Windows 上通过stdio_client启动某个 Python 写的 MCP Server 时Client 端直接报error: 拒绝访问。 (os error 5) to work without the background server, rerun当时的直观感受是权限不够以为是终端没有管理员权限。后来排查才发现问题出在 MCP 的 stdio 传输机制上——它要求 Server 子进程的 stdout/stderr 被重定向。在 Windows 的某些环境下尤其是用了 Python 的subprocess结合 shell 重定向时旧版本 SDK 的管道创建方式会碰到句柄继承问题导致 client 无法向其 stdout 写入 JSON-RPC 请求。解决方案有三个按推荐顺序排升级 SDK 到最新版。这个问题在 2025 年发布的新版 MCP SDK 中修复了Windows 下句柄继承逻辑重写过。改用 Streamable HTTP 传输。把 Server 起成一个 HTTP 服务Client 用streamable_http_client连接绕开本地管道。避免在 shell 包装层启动 Server。直接在StdioServerParameters里给原始 Python 解释器路径和模块参数不要套一层cmd /c或powershell -Command这会让管道重定向多包一层更容易触发句柄问题。我最终选了方案 2因为本地 HTTP 方式还能顺带解决跨机器问题——Server 跑在另一台构建机上Agent 从开发机远程调用这是 stdio 完全做不到的。5.2 Client 端超时与 Server 端僵尸进程stdio 模式的另一个典型问题Server 子进程起来后如果你的 Client 代码因为异常提前退出Server 进程并不会自动回收会变成僵尸进程占着端口或文件句柄。下一轮启动同名 Server 时可能因为端口被占用直接失败或者因为旧进程还持有某些文件锁导致行为异常。我的处理习惯是给每个 Server 子进程加atexit钩子确保在 Client 退出时主动终止子进程同时给tools/call加上合理的超时控制。MCP 的调用不像本地函数它是一次跨进程 RPC网络波动、Server 自身卡死都会导致调用挂起。建议把所有长耗时工具的超时设到两分钟以上但不要完全不设。5.3 工具数量膨胀导致的上下文污染这是方案 B全打平最致命的问题。我最多一次给单个 Agent 挂了 18 个 MCP 工具结果模型开始频繁选择错误的工具尤其是执行相似操作的工具比如写文件和追加文件总是混。排查下来发现根源是工具 description 互相干扰模型在几百行的工具描述里找不到精确的语义边界。调整思路精简描述。MCP Server 端的 description 要克制把核心用途、关键注意事项、返回格式写清楚即可不要写成一篇散文。子 Agent 隔离。用方案 C 替代全打平按职责分给不同 Agent每个 Agent 的工具清单控制在 5~8 个。给工具分组命名。用统一前缀如file_、db_、web_方便模型快速归类。5.4 远程 Server 的鉴权问题token exchange failed如果你的 MCP Server 走 OAuth 鉴权新版 Streamable HTTP 的推荐做法你可能会看到login server error: token exchange failed: token endpoint returned ...这个错误大概率是 Client 向 token endpoint 发起授权码换令牌的请求时Server 校验不了 Client 的认证信息。常见原因是 scope 或 audience 不匹配——Client 声明的权限范围比 Server 允许的宽或者 token endpoint 要求的client_id没有在请求里正确携带。排查路径先看 Server 日志里 token endpoint 收到的具体请求是什么再比对 Client 配置里写的 scope、audience 和实际 Server 配置是否一致。很多时候不是协议理解问题就是两边配置没对齐。另一个类似的热词场景是 Docker 里面跑 MCP Server然后 Client 报内部错误提示 Docker Desktop Linux Engine 之类的版本不匹配。本质是 Client 请求的 API 版本和 Server 端 Docker Engine 支持的版本不一致——这类问题直接看 API version 协商机制MCP 本身反而很少参与。5.5 关于命名和管理上的经验最后分享一点管理层面的体会。当你的项目里有五六个 MCP Server 时我建议做一个配置文件统一管理{ servers: { files: { transport: stdio, command: python, args: [-m, mcp_files] }, db: { transport: http, url: http://127.0.0.1:8000/mcp, headers: {Authorization: Bearer ...} }, internal-docs: { transport: http, url: https://docs.internal.example.com/mcp } }, routing: { file_ops: [files], data_analysis: [db], research: [internal-docs] } }然后用一个工厂函数根据这个配置批量创建 Client 会话按路由分组把工具分配给不同的 Agent。这样既有配置文件的可读性又有代码里的灵活性——新增一个 Server 就是改一行 JSON不用动业务代码。这套模式我在多个项目里反复用稳定性一直不错。MCP 这个生态还在快速变化但协议层的核心设计已经稳定下来了握手、能力协商、工具发现、函数调用这四个步骤值得花时间彻底吃透。更重要的是把多 Server 组织当成一个架构问题来思考而不是简单堆工具列表——工具永远是为你服务的别让它们反过来淹没你的模型决策。