MCP协议握手与LangGraph多Server接入实战解析
发布时间:2026/10/27 3:51:52 锦皓数字建站

这周我帮一个团队把他们的业务系统接进 AI Agent 时对方负责人问了我一个特别直接的问题为什么我们不能直接调内部 API非要绕一圈搞个 MCP我用一句话回答了他因为你要接的不是一个 API而是几十个 API而且这些 API 来自不同的系统、不同的团队、不同的鉴权方式。MCPModel Context Protocol解决的就是这件事——它把“给 AI 模型接工具”这件事标准化了从协议握手开始到工具发现、工具调用、上下文注入全部有一套公共约定。这篇分享我会从协议层面拆解 MCP 握手的完整链路然后带大家用 LangGraph 把多个 MCP Server 接进同一个 Agent 里协同干活最后附上我在真实项目里排错的三个问题和解决过程。这东西没有网上传的那么玄但细节确实不少。1. 先从“为什么要用 MCP”说起工具接入的乱世需要一套通用规范1.1 没有协议之前AI 工具接入是什么样子如果你做过 Chatbot 或者 Agent 类的项目一定经历过这种阶段为了让模型能查天气、能搜数据库、能调用内部系统你给大模型写了几十个 function schema每个工具都要自己定义参数结构、自己处理鉴权、自己管理响应格式。短期接两三个工具还行一旦工具超过十个代码里几乎全是胶水逻辑——工具签名变了要改认证过期了要处理模型偶尔还把参数传错你还要做一层容错。这个阶段其实是在重复造轮子每一个 AI 应用都在用自己的一套方式对接外部能力。MCP 的出现就是来终结这种各搞一套的局面。它把“AI 应用”和“外部工具/数据源”之间划出了一条清晰的协议边界AI 应用只需要实现一个 MCP Client外部能力只需要提供一个 MCP Server两者之间通过标准化的 JSON-RPC 2.0 消息通信。这样任何一个支持 MCP 的 AI 应用都可以直接复用社区里已有的各种 Server——文件系统、数据库、浏览器自动化、地图、行情全部即插即用。1.2 MCP 的角色模型Host、Client 与 ServerMCP 的架构里主要有三个角色理解清楚这几个角色后面看握手和调用才不会晕Host模型所在的宿主程序比如 Claude Desktop、IDE 插件、你自研的 Agent 应用。Host 负责管理对话上下文并决定要不要调用工具。ClientHost 内部与 MCP Server 建立连接的组件负责发送 JSON-RPC 请求、维护会话生命周期。Server暴露工具、资源、提示词的进程或服务。它可以是本地子进程stdio也可以是远程 HTTP 服务。我在帮团队接入时最常听到的疑问是“Server 是不是就是 Agent”。不是。Server 是工具提供方它不决定模型怎么用工具Agent 是决策方它决定要不要用、用哪个、怎么组合。MCP 把一个 Server 里的若干工具暴露给 Host 之后具体调用多少次、按什么顺序调用依然是 Host 侧 Agent 的逻辑。理解这一点对后面设计 LangGraph 多 Server 调用非常关键。2. 协议握手核心链路一条 initialize 消息背后的 JSON-RPC 对话2.1 MCP 的消息底座JSON-RPC 2.0MCP 的消息格式没有另起炉灶而是直接复用了 JSON-RPC 2.0。这意味着所有通信都是请求-响应模型或者通知模型消息本身就是一个 JSON 对象。常见结构有三种Request带id、method、params对端必须返回带有相同id的 Response。Response带id、result或error其中的id必须和对应的 Request 一致。Notification只有method、params没有id对端不需要返回任何内容。这里面最需要新手注意的就是id。一次正常的握手initialize请求的id通常是 1服务端响应里的id必须也是 1这是 JSON-RPC 的基本约束。我见过有人直接拿 SDK 调试时发现客户端一直报 “Unknown response”结果是自己写了个中间层把响应里的id字段给改了。2.2 完整握手的四个关键步骤一次 MCP 连接的建立本质上就是客户端和服务端互相确认“我是谁、我支持什么、你能做什么”的过程。拆开看是四步第一步客户端发送 initialize 请求。客户端先发送一条initialize请求这一步会带上三块信息客户端支持的协议版本protocolVersion、客户端能力capabilities、客户端自身标识clientInfo。举个例子{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: { roots: { listChanged: true }, sampling: {} }, clientInfo: { name: my-agent-app, version: 1.0.0 } } }第二步服务端返回 initialize 响应。服务端收到后会返回自己的协议版本、能力列表和标识。注意这里服务端不一定完全接受客户端请求的版本它可以在响应里声明自己实际选择的版本{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true } }, serverInfo: { name: demo-server, version: 0.1.0 } } }第三步客户端发送 initialized 通知。收到响应之后客户端要主动发一条notifications/initialized通知告诉服务端“握手完成后续可以开始正常业务通信”。这是一条没有id的通知消息服务端不会返回响应。很多人调试时会忽略这一步导致服务端一直不响应后续的tools/list或者日志里报 “Server not initialized”。第四步进入正常通信阶段。握手完成后客户端就可以发送tools/list、tools/call、resources/list、prompts/list等请求来完成实际业务。整个生命周期的末尾客户端可以主动断开连接服务端也可以根据心跳超时回收会话。2.3 从日志看一次真实握手我习惯在调试时把 MCP 的原始 JSON-RPC 消息打到日志里这一步比任何文档都直观。一次标准握手的日志序列是这样的[client] -- {jsonrpc:2.0,id:1,method:initialize,params:{...}} [server] -- {jsonrpc:2.0,id:1,result:{protocolVersion:2025-03-26,...}} [client] -- {jsonrpc:2.0,method:notifications/initialized} [client] -- {jsonrpc:2.0,id:2,method:tools/list,params:{}} [server] -- {jsonrpc:2.0,id:2,result:{tools:[...]}} [client] -- {jsonrpc:2.0,id:3,method:tools/call,params:{name:...,arguments:{...}}} [server] -- {jsonrpc:2.0,id:3,result:{content:[{type:text,text:...}]}}这里有一个经常被忽略的字段protocolVersion。不同的服务端 SDK 可能默认返回不同的协议版本如果你写死要求某个版本就容易在版本协商上出问题。SDK 的做法通常是客户端请求它支持的最高版本服务端选择自己支持的版本返回。所以版本协商更像是“互相确认一个都能接受的版本”而不是“客户端定死了就必须用那个”。3. 传输层不是小事stdio 与 Streamable HTTP 的选型细节3.1 本地进程用 stdio远程服务用 HTTPMCP 的传输层目前主流有两种stdio和Streamable HTTP。选错传输层是很多项目从 Demo 走向生产环境时翻车的第一原因。stdio客户端启动一个本地子进程比如用 npx 启动一个 Node 写的 MCP Server通过子进程的标准输入输出传递 JSON-RPC 消息。这是最简单的模式适合本地文件系统操作、命令行工具封装、IDE 内嵌的辅助工具。优点是免去端口和网络配置缺点是只能服务本地单一客户端。Streamable HTTP服务端是一个 HTTP 服务客户端通过 POST 请求发送消息服务端通过 SSEServer-Sent Events或普通 HTTP 响应返回结果。适合远程服务、多客户端共享、需要鉴权和生产化部署的场景。我给你的选型建议就一句话能用本地子进程解决的先用 stdio凡是涉及多系统共享、跨机器调用、需要统一鉴权的直接上 Streamable HTTP。LangGraph 这边两种都支持但在代码里配置方式差别挺大。3.2 Streamable HTTP 的握手流程与会话管理Streamable HTTP 并不是普通的 REST 调用。它把 JSON-RPC 消息放在 HTTP POST 的 body 里但会话状态和 SSE 流是它的核心难点。一次 Streamable HTTP 连接大致是这样的客户端发送 POST/mcpbody 是initialize请求。服务端处理完成后除了返回 JSON-RPC 响应还会在响应头里带上Mcp-Session-Id。客户端必须保存这个 Session ID在后续所有请求的 Header 里带上它否则服务端会直接拒绝。如果某个请求需要服务端长时间推送结果服务端会建立一条 SSE 流客户端通过 GET 请求连接这条流并持续读取事件。这里最容易踩的坑是 Session ID 的时效性。很多服务端会对空闲会话做超时回收比如 5 分钟没有请求就会把 Session 清掉。客户端如果还拿旧 Session 发请求就会收到会话失效的错误。解决方法是客户端做断线重连发现会话失效后重新走一遍initialize握手拿到新 Session 再继续。HTTP 传输还有一个点是鉴权。远程服务的 MCP 端口如果裸奔在公网上风险非常大。常见做法是在 HTTP 层加 Bearer Token或者直接上 OAuth 2.0。比较新的协议版本里面MCP 已经定义了标准 OAuth 流程但实际接入时你依然要先确认 Server 端的认证规范别想当然。3.3 传输层最容易翻车的三个地方第一个Windows 下 stdio 子进程启动失败。用 npx 启动 Node Server 时如果没指定 shellWindows 上经常出现找不到可执行文件的问题。这个没太多捷径在配置里显式设置shellTrue并且先手动在命令行里跑一遍启动命令确认能起来再交给 MCP Client。第二个streamable HTTP 的超时时间太短。很多工具调用耗时不短比如搜索、数据库聚合、浏览器自动化两三秒都算快的。如果客户端默认超时只有 30 秒一次稍微复杂点的工具调用就会直接中断。代码里一定要给 Client 显式配置 timeout或者让服务端改成先返回“任务已开始”再通过 SSE 推送最终结果避免长时间阻塞 HTTP 响应。第三个stdio 和 HTTP 的调试姿势完全不同。stdio 模式可以直接在终端手动输入 JSON-RPC 去调试HTTP 模式则必须关注 Header、Session、连接状态。建议从一开始就把“原始消息日志”打开很多问题光看上层报错根本定位不了。4. LangGraph 多 Server 调用从 MultiServerMCPClient 到 Agent 编排4.1 为什么需要把多个 MCP Server 拉进同一个 Agent实际业务场景里几乎没有哪个 Agent 只需要一个工具。我最近搭的一个内部流程自动化应用就需要同时操作三套服务一个负责查业务数据库一个负责文件读写一个负责调用外部地图和办公套件。如果用单 Server 的思路做就得把这些能力全都揉进一个服务里耦合度非常高而 MCP 本身的设计就是“一个 Server 管一件事”所以自然要用多 Server 架构。LangGraph 是基于图结构的 Agent 编排框架节点Node负责具体执行边Edge负责流转控制状态State在节点之间传递。MCP Server 暴露出来的工具在 LangGraph 里可以注册为节点可调用的 Tool这样既能保留多 Server 的工具能力又能用 LangGraph 的状态机和条件分支去编排它们的调用顺序。这是我认为目前把 MCP 落进生产系统比较舒服的组合方式。4.2 MultiServerMCPClient 的接入写法LangChain 生态里有一个现成的适配库langchain-mcp-adapters里面提供的MultiServerMCPClient就是为多 Server 场景设计的。它的用法非常直白构造一个字典key 是 Server 的名称value 是连接配置然后用async with进入上下文拿到对应的 Tool 列表。from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent async with MultiServerMCPClient( { db-server: { url: http://localhost:8000/mcp, transport: streamable_http, headers: {Authorization: Bearer token123}, timeout: 120, }, fs-server: { command: npx, args: [-y, modelcontextprotocol/server-filesystem, /tmp/data], transport: stdio, }, } ) as client: tools await client.get_tools() agent create_react_agent( modelChatOpenAI(modelgpt-4o), toolstools, prompt( 你有来自多个 MCP Server 的工具可用。 db-server 负责数据库查询fs-server 负责文件操作。 涉及查询用 db-server 下的工具涉及读写文件用 fs-server 下的工具。 ), ) result await agent.ainvoke({messages: [(user, 查一下上个月的订单总量导出成报告写到 /tmp/data 下)]})这段代码里MultiServerMCPClient会在进入上下文时自动建立所有 Server 连接完成握手然后拉取每个 Server 的工具列表。你不需要手动去写initialize、tools/list这些底层逻辑适配库都帮你处理了。4.3 工具名冲突与命名空间规则多 Server 场景下最容易被忽视的问题就是工具名冲突。假设db-server和fs-server都有一个叫query的工具默认情况下两者会互相覆盖后面加载的会覆盖前面加载的运行时会报“工具不存在”或者调用到错误的工具。langchain-mcp-adapters的默认行为是给工具名加上 Server 名前缀比如db-server.query和fs-server.query。模型看到的是这种带前缀的完整名字。这里要注意两点前缀格式在不同版本里略有差异有的是下划线db_server_query有的是点号db-server.query。别去记死格式直接打印tools列表看一眼最可靠。加了前缀之后模型的决策能力会受影响。你需要把每个 Server 的职责边界写清楚最好在 Agent 的 system prompt 里明确说明“xx 前缀的工具负责什么”否则模型可能分不清该用哪个。4.4 把它挂进 LangGraphcreate_react_agent 与自定义图create_react_agent是最快的接入方式适合标准 ReAct 循环。它的好处是默认会把所有工具注册到一个循环里模型自己决定调哪个工具、按什么顺序调用。但如果你需要强制走固定流程比如“先查库存再下订单库存不足就不调用下单工具”就得换成自定义图。自定义图的思路是这样的用StateGraph定义几个节点每个节点内部绑定某一个 MCP Server 的工具调用逻辑节点之间用条件边控制流转。这样编排逻辑完全掌握在自己手里可控性比纯 ReAct 强很多。from langgraph.graph import StateGraph, END from typing import TypedDict class AgentState(TypedDict): query: str db_result: str file_path: str async def query_db(state: AgentState): result await db_tool.ainvoke({query: state[query]}) return {db_result: result} async def write_report(state: AgentState): path await fs_tool.ainvoke({content: state[db_result], path: /tmp/data/report.md}) return {file_path: path} graph StateGraph(AgentState) graph.add_node(query_db, query_db) graph.add_node(write_report, write_report) graph.add_edge(query_db, write_report) graph.add_edge(write_report, END) app graph.compile()这种写法的好处是执行顺序是确定性的不会让模型随意跳步骤。缺点是你要自己处理状态传递和异常分支。我的建议是Demo 阶段用create_react_agent跑通链路真正上生产、流程固定之后再逐步迁移到自定义图。5. 真实项目排错实录三个问题定位过程5.1 问题一握手成功但工具列表为空现象MultiServerMCPClient启动没报错日志里initialize也正常返回了但最终拿到tools是空列表。排查过程我先看 Server 端的initialize响应里capabilities字段发现只声明了resources能力没有声明tools。MCP 协议里有这个约定如果服务端没有在 capabilities 里声明某一类能力客户端就不应该请求对应的列表。所以tools/list虽然在发但服务端直接返回空数组。解决方式有两个一是改 Server 端在 capabilities 里加上tools声明二是确认是否存在版本兼容问题——有些 Server 老版本不支持 tools必须升级 SDK。这个问题本身不难但如果只看上层日志很容易被“握手成功”这个假象误导浪费好几个小时。5.2 问题二第二个 Server 的工具在运行时“消失”现象两个 Server 都注册成功早上的测试一切正常下午再跑时 agent 频繁说“找不到工具”反复重试也没用。排查过程我先把全部工具名打印出来发现两个 Server 下存在同名工具而且在某个时间点开始后加载的 Server 工具把先加载的同名工具覆盖了。这种问题在单进程长生命周期应用里特别隐蔽因为加载顺序和工具注册时机受异步初始化影响不是每次都能稳定复现。解决方式给每个 Server 配置明确的工具前缀同时在日志里打印注册完成后的工具清单跟预期比对。我后来加了一个启动断言注册完工具后自动核对预期数量少了就直接启动失败避免带病上线。5.3 问题三长耗时工具触发客户端超时现象调用地图服务或数据库统计工具时agent 运行到一半就断了错误信息是 “Request timed out”。排查过程我看了一眼服务端日志发现工具本身执行成功了只是花了 90 多秒而客户端默认超时只有 30 秒。也就是说服务端已经算完了但因为响应回传超时客户端直接放弃了。这不是 MCP 协议的问题而是客户端配置的问题。解决方式给对应的 streamable_http 配置加timeout参数我直接调到了 120 秒。另外我还改了一个更稳妥的写法对于确实需要很长时间的工具服务端先返回一个任务 ID客户端轮询任务状态不在单次请求里阻塞太久。这个设计在有大量数据聚合的场景下很实用。问题根本原因解决方式工具列表为空Server capabilities 未声明 tools修改 Server 能力声明或升级 SDK工具运行中消失多 Server 工具名冲突显式添加工具前缀并做启动校验长耗时工具中断客户端超时时间过短调大 timeout 或改为异步任务轮询6. 写在搭建完成之后性能调优与方向扩展6.1 连接生命周期管理MCP Client 的连接是有状态的尤其是 Streamable HTTP 的 Session不能每次都随手 new 一个。实践里我通常用两种模式短生命周期每个 Agent 调用请求单独建连接并关闭。适合低频调用逻辑简单但握手开销大。长生命周期初始化一次把 Client 和 Tool 列表缓存在服务里后面请求复用。适合高频生产环境但要注意 Session 超时和连接回收。我一般在缓存里记录连接的最后使用时间超过一定空闲时间就主动重连避免被服务端静默回收。这两种模式在 LangGraph 里对应两种用法前者用async with随用随关后者把async with拆成显式的__aenter__和__aexit__将 Client 对象存到服务实例里。我推荐生产环境用后者。6.2 从 REST 接口快速改造成 MCP Server不少团队手里已经有一套成熟的 REST API不想为了 MCP 重写业务逻辑。这个其实可以很轻量用一个 MCP SDK 写一个薄包装层把已有的 REST 接口逻辑原样包进去对外暴露成 MCP 工具即可。以 Python 为例fastmcp库可以把一个普通函数直接暴露成 MCP 工具函数内部去调旧 REST 接口非常快。Java 生态的话Spring AI 提供了 MCP Server 的自动配置可以把已有的 Service 方法直接注册成 MCP Tool。核心原则是别在 MCP Server 里重写业务逻辑它只做“翻译”和“转发”真实逻辑留在原有服务层。6.3 流式输出与领域化 Agent 的下一步MCP 协议是最早关注流式输出问题的方向之一。很多工具调用的结果不是一次性内容而是文件写入、数据流、长时间任务日志。我们在实际使用中已经用上了 MCP 的流式响应能力配合 LangGraph 的流式输出可以在文件写入过程中实时把状态反馈给用户体验比干等好太多。另外国内的服务生态也起来了。地图、行情、办公自动化这些领域的服务商开始推出自己的 MCP Server也就是说未来接一个“能看地图的 Agent”或者“能查行情的 Agent”可能不需要自己写工具直接连一个远程 Server 就行。这个方向我很看好但也要提醒一句远程 Server 的鉴权和数据安全一定要在接入前做好评估。搭建完这套多 Server 调用链路之后我个人最大的体会是MCP 的价值不在于它省掉了多少代码而在于它把 AI 应用和外部工具之间的接口彻底统一了。协议握手这些底层细节SDK 大部分都封装好了但理解它依然很重要——因为只有理解了握手、会话、超时这些概念你才能在生产环境里快速定位问题。最后再分享一个小技巧无论用哪种 SDK从第一天起就把原始 JSON-RPC 日志留下来后续排查任何握手和调用问题这东西比看十遍文档都有用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。