news 2026/10/19 16:34:54

MCP协议握手与LangGraph多Server调用:从原理到实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCP协议握手与LangGraph多Server调用:从原理到实战

最近只要聊 Agent 开发,绕不开的词就是 MCP。Model Context Protocol,模型上下文协议,说白了就是一套让大模型和外部工具交互的标准化协议,被大家叫成 AI 界的 USB-C 也是这个原因。我这两个月从零把 MCP 协议握手捋了一遍,再把 LangGraph 和多个 MCP Server 串在一起跑了一个完整的 Agent,中间踩了不少坑。这篇把这条链路完整拆开讲:握手时客户端和服务端到底聊了什么,Server 怎么选、怎么接,最后给一个可以直接改改就用的 LangGraph 多 Server 调用示例。

适合谁看?如果你正准备给 Agent 接工具,又不想一个工具一个工具地手写 function calling,或者你已经在用 LangGraph,但还没搞明白多个 MCP Server 之间怎么协作、工具名冲突怎么解决,这篇可以帮你省掉不少试错时间。小到本地文件操作、浏览器自动化,大到接入 SQL Server、Playwright、IDA 这类重型工具,思路是通用的。

1. MCP 协议握手:双方在 initialize 里交换了什么

1.1 为什么需要握手,以及 MCP 的消息底色

很多人第一次接触 MCP,上来就写tools/list、tools/call,然后发现连不上,一脸懵。原因很简单:MCP 不是 HTTP 那种"请求-响应"随到随用,它要求客户端和服务端先完成一次握手,像两个人第一次见面,先递名片、确认语言版本、说清楚自己会什么,然后才开始正式对话。

MCP 的底层消息格式是 JSON-RPC 2.0,每条消息都有jsonrpc、method、params这些字段。握手阶段的核心是两个动作:一个是带id的请求,比如initialize;一个是不带id的通知,比如notifications/initialized。我见过不少初学者把这两者混在一起,明明服务端已经返回了initialize结果,却漏发了initialized通知,结果后续请求直接超时。记住:这是两段式确认,缺一不可。

1.2 initialize 与 initialized:不是一次请求,而是两段式确认

客户端发起initialize请求时,大致长这样:

{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {}, "resources": {} }, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } }

服务端收到后返回自己的能力声明:

{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {} }, "serverInfo": { "name": "files-server", "version": "0.1.0" } } }

这里有个容易忽略的点:capabilities不是摆设。客户端声明自己支持tools和resources,服务端声明自己只支持tools,两边就默认只走工具调用这条路。如果你的客户端还尝试请求resources/read,服务端会直接拒绝或者返回一个"不支持"的错误。所以握手的本质是能力交集协商,而不是单方面宣布。

随后客户端必须发一个initialized通知,注意它没有id,不需要响应:

{ "jsonrpc": "2.0", "method": "notifications/initialized" }

从这一刻起,连接才算真正"可用"。

1.3 从握手到 tools/list:工具描述决定了模型能不能用对

完成握手后,客户端就可以调用tools/list获取服务端能力清单。这一步看似简单,但直接影响后续 Agent 的智商。MCP Server 返回的不是函数源码,而是给大模型看的"说明书",每个工具包含name、description、inputSchema:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }

返回结果里会有一个tools数组,每一项大概长这样:

{ "name": "read_file", "description": "读取指定路径的文本文件内容,文件必须存在", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "description": "文件的绝对路径或相对路径" } }, "required": ["path"] } }

我自己的经验是:description写得越离谱,模型选工具就越离谱。比如你把read_file写成"读取文件",模型在多个工具之间犹豫的概率就变大;如果你写清楚"适合读取日志、配置、代码源文件,不接受目录路径",模型基本不会乱选。inputSchema里的字段描述也是同样的道理,它相当于给模型的一份填空指南。

1.4 版本协商不是可选项:protocolVersion 的兼容处理

MCP 的protocolVersion是日期字符串,比如2024-11-05、2025-03-26、2025-06-18。最坑的场景是:你拿最新的 SDK 去连一个只支持旧版本的 Server,结果initialize返回一个"Unsupported protocol version"错误。这不是网络问题,是话语体系不一致。

常规做法是客户端先发自己支持的版本,如果服务端返回错误,客户端再降级重试;或者客户端在启动参数里显式指定一个保守版本,比如2024-11-05,保证兼容大多数老 Server。如果你只是写内部工具,两边都用同一版本 SDK,问题不大;但一旦要接社区里那些星数不高、维护停滞的 Server,版本兼容就非常关键。我建议在代码里不要硬编码版本号,而是封装一个初始化函数,把"服务端返回的 protocolVersion"当作当前连接的最终版本,后续请求都用这个值。

2. Server 选型与接入方式:本地进程、远程服务,还有 REST 转 MCP

2.1 stdio 与 Streamable HTTP:两条主流接入路线的取舍

MCP Server 的传输方式主要分两类。一类是 stdio,客户端直接拉起一个本地子进程,通过标准输入输出通信;另一类是 Streamable HTTP,客户端通过 HTTP 接口访问远程服务。这两者的使用场景差别很大,我用一张表整理一下:

维度stdioStreamable HTTP
部署位置本地进程远程服务
适用场景文件系统、本地工具、浏览器自动化、逆向调试共享 Server、多人团队、鉴权服务
安全隔离好,不暴露端口差一点,需要鉴权和网络策略
进程管理客户端负责拉起和销毁服务端独立运行
典型例子IDA MCP、x32dbg 插件、Playwright MCP、SQLite Server团队共享的数据库查询服务、统一 API 网关

如果你只是自己电脑上调试,stdio 最省事,不用开端口、不用配鉴权,客户端退出进程就结束。但注意 stdio 也有短板:跨机器调用得先封装成远程服务,否则别人没法直接用。我有个项目一开始图省事全用 stdio,后来同事要远程调用,又花时间包了一层 HTTP 封装,早知道一开始就设计成可切换的传输层会更顺。

2.2 没有 MCP 也能写 Agent,为什么我最后还是选了 MCP

这个问题的答案很简单:没有 MCP,你用 function calling 也能开发 Agent,我自己以前就是手写 JSON Schema 再传给模型。但工具一多就发现问题:每个工具都要自己维护参数描述、处理并行调用、调试返回格式,换一个模型厂商可能还要调整调用方式,非常痛。

MCP 的价值不在于"让 Agent 能调工具",而在于把工具的定义、发现、调用、返回标准化了。社区里有大量现成 Server,像 Playwright MCP 做浏览器自动化、IDA MCP 做逆向交互、x32dbg 的 MCP 插件、SQL Server 管理这类,基本是拉下来就能跑。省下的时间远比那点学习成本值。所以我的结论是:MCP 不是必需品,但它是省力器,尤其是当你接的第三方系统超过两个的时候,收益非常明显。

2.3 快速把一个 REST 接口变成 MCP:OpenAPI 转接路径

很多团队问我:我们有一堆 Java、Python 写的 REST 接口,怎么快速让 Agent 用起来?其实路径很成熟,不需要一个接口一个接口包装。只要你的 REST 服务有 OpenAPI 文档(Spring Boot 很容易生成),社区有工具能基于 OpenAPI spec 自动生成 MCP Server,工具名从接口路径来,参数从请求体映射过来,认证信息做成环境变量。Java 侧也有 Spring AI 的 MCP Server 模块,可以让 Spring MVC 的 Controller 自动暴露成 MCP 工具。

这种方式特别适合老系统改造。我见过一个内部工单系统,几百个 REST 接口,通过 OpenAPI 自动转出几十个 MCP 工具,Agent 就能直接查工单、改状态,业务代码一行没动。

2.4 数据库类 Server 的真实坑:权限、行数、日期序列化

数据库是 MCP Server 最常见的落地场景之一,SQL Server、MySQL、SQLite 都有现成实现。但接入时我建议强制做三件事:第一,给 MCP Server 配一个最小权限账号,只读账号最佳,绝不给 DDL 权限;第二,在 Server 侧强制加LIMIT,不然模型生成一条SELECT * FROM orders直接拖垮服务;第三,注意日期字段的序列化问题,SQL Server 的datetime类型返回后经常引发格式转换失败,我见过最典型的就是conversion failed when converting date and/or time这种报错。稳妥的做法是在 SQL 里直接CONVERT(varchar, order_date, 120)转成字符串,让下游少一层解析负担。

3. LangGraph 多 Server 调用的两种架构:单 Agent 合并与多 Agent 路由

3.1 LangGraph 的循环机制:模型、工具、状态三者的关系

LangGraph 本质上是一个有状态的工作流引擎。做一个 Agent 时,核心是"模型决定调用哪些工具 -> 执行工具 -> 结果写回状态 -> 模型继续判断"这个循环,直到模型认为不需要再调工具为止。它和普通for循环不同的是,每一步的上下文都保存在 state 里,LangGraph 负责维护消息列表和工具执行结果。

这意味着多 Server 调用不是"同时调多个 Server",而是"让同一个 Agent 在不同回合里选择不同 Server 的工具"。模型看到的工具列表是平铺的,它得根据对话内容自己决定用files还是db。所以工具描述里最好带上领域标签,比如"来自文件服务""仅用于数据库查询",否则模型在多工具池里容易乱选。

3.2 方案一:一个 Agent 挂多 Server(小规模首选)

最直接的方案是把多个 MCP Server 的所有工具合并成一个列表,传给同一个 Agent。LangGraph 的create_agent接受一个工具数组,你只要加载完所有 Server 的工具后bind给模型就行。我之前用MultiServerMCPClient初始化多个 stdio Server,拿到所有工具后直接创建 Agent,效果相当不错。

这个方案适合工具数量在 20 个以内的场景。优点是代码简单、决策链路短,模型能看到全部能力,跨 Server 的组合调用(先查文件再查数据库)非常自然。缺点是工具一多,模型容易糊涂:两个 Server 都定义了get_info,它可能选错。

3.3 方案二:多子 Agent 加路由节点(工具多了以后)

当工具数量超过 20 个,或者领域隔离很明确时,我推荐拆成多个子 Agent。每个子 Agent 只绑定一个 MCP Server 的工具,外层一个主管 Agent 负责路由:先根据用户问题决定找文件子 Agent 还是数据库子 Agent,再把子 Agent 的输出汇总。LangGraph 里可以用一个router节点加上多个子 Agent 节点来实现。

这个方案的工程复杂度高一些,但可维护性好很多。每个子 Agent 的工具池小,模型选工具准确率明显上升;主管 Agent 不需要看到几十个工具的细节,只需要理解"找谁处理"这件事。

方案适合场景缺点
单 Agent + 多 Server工具少、跨领域组合多工具多时容易选错
多子 Agent + 路由工具多、领域隔离清晰需要额外设计路由逻辑

3.4 工具重名的冲突处理与能力裁剪

多 Server 必然遇到工具重名问题。我的做法是在加载工具后统一加前缀,比如files_read_file、db_read_file,同时在工具的description最前面标注来源。你可以写个小函数遍历工具列表,按 Server 名做前缀映射。另一个做法是"能力裁剪":不是每个 Server 的所有工具都适合暴露给模型。比如数据库 Server 里有一条DROP_TABLE工具,我建议直接过滤掉,只暴露只读查询相关的少数几个工具,安全性和可用性都更好。模型不是你,它不会自动判断哪些工具危险,你必须在入口处替它设好边界。

4. 实操:一个 Agent 串联文件系统 Server 和数据库 Server

4.1 环境准备和目录结构

这个示例里我会建两个最小的 MCP Server:一个提供文件读写,一个提供 SQLite 查询,然后通过 LangGraph 把它们串成一个 Agent。环境需要 Python 3.10+,安装依赖:

pip install "langgraph>=0.2" langchain-mcp-adapters langchain-openai mcp

目录结构:

mcp-multi-demo/ ├── server_a.py # 文件服务 ├── server_b.py # 数据库服务 └── agent.py # LangGraph Agent

如果你的模型服务不是 OpenAI,也可以改用任何兼容 OpenAI 接口的模型地址,LangGraph 都支持。代码里我会写一个环境变量读取,方便替换。

4.2 两个 MCP Server 的最小实现

先写server_a.py,用 FastMCP 快速定义一个文件服务:

from mcp.server.fastmcp import FastMCP mcp = FastMCP("files") @mcp.tool() def list_files(path: str = ".") -> list[str]: """列出指定目录下的所有文件和文件夹名称,path 必须是已存在的目录。""" import os return os.listdir(path) @mcp.tool() def read_file(path: str) -> str: """读取指定文本文件的内容,path 必须是文件而非目录。""" with open(path, "r", encoding="utf-8") as f: return f.read() if __name__ == "__main__": mcp.run(transport="stdio")

再写server_b.py,提供一个 SQLite 查询工具,并自动初始化一张演示表:

import sqlite3 from mcp.server.fastmcp import FastMCP mcp = FastMCP("sqlite-db") @mcp.tool() def query(sql: str) -> str: """对本地 demo.db 执行只读 SQL 查询,最多返回前 20 行,禁止执行写操作。""" conn = sqlite3.connect("demo.db") try: cur = conn.execute(sql) cols = [d[0] for d in cur.description or []] rows = cur.fetchmany(20) return str({"columns": cols, "rows": rows}) finally: conn.close() if __name__ == "__main__": conn = sqlite3.connect("demo.db") conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)") conn.execute("INSERT OR IGNORE INTO users VALUES (1, 'Alice'), (2, 'Bob')") conn.commit() conn.close() mcp.run(transport="stdio")

注意两个点:一是工具的description都写清楚了边界,这直接影响模型选择;二是query里我用fetchmany(20)限制了行数,避免一次性返回巨大结果。

4.3 用 MultiServerMCPClient 统一加载并创建 Agent

接下来是核心的agent.py。MultiServerMCPClient的好处是可以一次性管理多个 stdio 子进程的启动、连接、关闭,而且支持流式传输:

import asyncio import os from langchain_mcp_adapters.client import MultiServerMCPClient from langgraph.prebuilt import create_agent from langchain_openai import ChatOpenAI async def main(): async with MultiServerMCPClient( { "files": { "transport": "stdio", "command": "python", "args": ["server_a.py"], }, "db": { "transport": "stdio", "command": "python", "args": ["server_b.py"], }, } ) as client: tools = client.get_tools() model = ChatOpenAI( model=os.getenv("AGENT_MODEL", "gpt-4o-mini"), temperature=0, ) agent = create_agent(model, tools) result = await agent.ainvoke({ "messages": [{ "role": "user", "content": "请读取 agent.py 的代码,并查询 demo.db 里 users 表有多少条记录。" }] }) for msg in result["messages"]: msg.pretty_print() if __name__ == "__main__": asyncio.run(main())

这段代码的妙处在于:你只需要配置 Server 字典,其他全部交给MultiServerMCPClient。握手、会话初始化、工具发现这些都自动完成。client.get_tools()返回的就是 LangChain 风格的工具,直接塞给create_agent即可。

4.4 跑通一次跨 Server 调用并观察工具选择逻辑

运行时你会看到 LangGraph 一步步输出:模型先发起一次工具调用,比如read_file,执行完后结果写回消息列表,模型继续判断"还需要查数据库",再调用query。这个过程不需要任何手工编排,是模型根据你的自然语言自己规划的。

我实际跑的时候建议打印result["messages"],观察工具之间的衔接是否自然。如果模型跳过了某个工具,大概率是工具描述不够清楚,或者path、sql这类参数描述太模糊。还有一个细节:我给query工具的 description 里写了"只读 SQL 查询",模型在它自己和read_file之间做选择时,明显更倾向把文件读取交给文件服务,把数据库查询交给数据库服务,说明描述里的领域标签起了作用。

5. 多 Server 协作中的报错排查与稳定性建议

5.1 握手失败的三类典型报错与定位方法

多 Server 接入后,常见报错基本集中在握手阶段。我整理了三个真实案例:

现象可能原因处理方式
Unsupported protocol version协议版本不兼容客户端降级到 Server 支持的版本,或升级 Server SDK
Connection closed during handshakestdio 子进程启动后就崩了手动在终端运行 server 脚本,检查 Python 语法、依赖、路径
tools/list超时Server 启动慢,客户端等待时间短增加握手超时时间;先单跑 Server,确认能快速响应请求

定位时我有个习惯:先把 Server 单独跑起来,用命令行手工发一条initialize,看它到底报什么错。这一步能过滤掉 80% 的环境问题,剩下再查客户端配置。

5.2 stdout 被污染:stdio 模式下最常见的翻车现场

这个问题我几乎每次都会被问到。MCP 的 stdio 传输要求 stdout 只能承载协议消息,不能有任何额外输出。如果你在 Server 代码里为了调试写了print("reading file..."),客户端解析 JSON-RPC 时会直接崩掉,报一个类似ContentError: Expected a JSON-RPC message的错。

正确做法是所有日志写stderr:

import sys @mcp.tool() def read_file(path: str) -> str: print("reading file", path, file=sys.stderr) ...

或者用logging输出到 stderr。生产环境里我建议把 Server 进程的 stderr 重定向到日志文件,方便回溯。记住这条规则之后,再也不会出现"明明单个工具没问题,挂到 Agent 里就报错"的诡异情况。

5.3 流式输出到文件的正确姿势

如果你需要在 Agent 执行过程中把模型输出流式写入文件,不要等全部结果组装完再写。LangGraph 提供astream_events,可以拿到模型 token 粒度的增量输出:

async for event in agent.astream_events(input, version="v2"): if event["event"] == "on_chat_model_stream": chunk = event["data"]["chunk"].content if chunk: if isinstance(chunk, list): chunk = "".join(str(c) for c in chunk) f.write(chunk) f.flush()

这样即写即刷,大段输出也不会撑爆内存。CherryStudio 这类工具处理 MCP 流式消息时也是类似的原理:服务端不断推送更新的增量,客户端逐段消费,而不是全部缓存完再展示。

5.4 给多 Server 协作加一点健康检查和超时余量

多个 Server 串联后,任何一个子进程挂掉都会拖垮整个 Agent。我现在的做法是:在启动阶段巡检每个 Server,挨个发起initialize和tools/list,谁失败就在日志里标红;运行阶段给每个 Server 请求加超时阈值,超过 10 秒就主动断开重连,而不是让 Agent 无限期等待。

还有一个容易被忽略的点:模型服务本身的超时也要设置,否则某个 Server 卡住,模型那边也一直等。我把超时策略分成三层:模型调用层、MCP 连接层、工具执行层,每一层都有自己的超时时间和重试策略。这样做的好处是,即便某个 Server 临时抖动,也只是重试一次,而不是整个 Agent 流程中断。

最后说一点个人体会。MCP 协议握手和 LangGraph 多 Server 调用,看似是两个知识点,实际是一条链路:握手的质量决定了后面工具发现的准确度,工具描述的清晰度决定了模型在多 Server 之间能不能正确选择,而超时和日志决定了这个系统能不能长期稳定跑下去。我现在的习惯是先画一张"工具归属表"——哪个工具属于哪个 Server、描述里要突出什么能力、哪些工具必须裁剪,再动手写配置。先想清楚边界,比事后debug省事得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 19:29:57

VAE在Stable Diffusion中的核心作用与科学选用指南

1. VAE不是“画图加速器”,而是Stable Diffusion里最被低估的隐性画师很多人第一次听说VAE,是在Stable Diffusion WebUI界面右下角那个不起眼的下拉菜单里——“VAE”选项,默认写着“None”或一个带“.safetensors”后缀的文件名。点开一看&a…

作者头像 李华
网站建设 2026/10/13 6:23:17

MATLAB常见问题诊断与优化:从报错定位到性能提升

聊 MATLAB 常见问题诊断与优化技巧,我觉得最值得先讲的是心态:大多数代码报错不是玄学,是你还没把报错信息拆到底。很多人一看到红色报错就慌,复制粘贴到搜索引擎,或者把循环改成向量化,甚至干脆重写一遍。…

作者头像 李华
网站建设 2026/10/13 15:56:09

DeepSeek-V2工业部署实战:中文术语优化与边缘推理调优

1. 这不是“笔记”,而是一份大模型工程师的实战手记我从2023年夏天开始系统性地啃DeepSeek系列模型,不是为了写PPT汇报,也不是为了凑论文,而是因为手头有个真实需求:给一家做工业设备预测性维护的客户部署一个能理解维…

作者头像 李华
网站建设 2026/10/14 23:10:28

数据价值链全解析:从采集到变现的五个关键环节

"大数据不是一堆日志,而是从采集到变现的完整价值链。很多人一提到大数据就想到算法、模型、可视化大屏,却忽略了前端的采集质量、中端的处理效率,以及最终端的变现目标。我做了几年数据项目,最深的体会是:链条上…

作者头像 李华
网站建设 2026/10/17 11:40:03

微PE工具箱实战指南:装机、数据恢复与系统维护

1. 项目概述:为什么一个装机工具箱值得你花15分钟认真读完微PE工具箱,这五个字在装机维修、系统恢复、数据抢救这些场景里,几乎等同于“最后一道保险”。我接触它是在五年前帮某高校实验室批量重装32台教学电脑时——当时用传统Ghost方案&…

作者头像 李华
网站建设 2026/10/16 6:50:09

从12306看高并发系统设计:查询、扣票与限流的极致实践

每年春节前两个月,我身边总有一群非程序员的朋友开始哀嚎“抢不到票”。他们经常会问我一个很有意思的问题:“你们程序员这么厉害,为什么12306还会崩?为什么我刷一下就卡死了?”说实话,这个问题在程序员圈里…

作者头像 李华