MCP(Model Context Protocol,模型上下文协议)这两年从概念到落地速度非常快。我最早注意到它是在 Claude 桌面版发布那段时间,后来发现 LangGraph、Dify 这些框架和平台都开始原生支持 MCP,连 IDE 插件(通义灵码、Codex)和工业软件(Altium Designer、TIA Portal)都在往这个方向靠。这篇文章不是官方文档翻译,是我从协议握手开始,到把多个 MCP Server 接进 LangGraph Agent 踩坑之后的完整复盘,适合正在了解 MCP 原理、准备把工具层标准化,或者想在 LangGraph 里组织多个 Server 调用的人。
1. 从协议握手说起:MCP 到底解决什么问题
1.1 工具接口碎片化,才是 MCP 诞生的真正原因
在 MCP 出现之前,Agent 对接外部工具的方式基本是各玩各的。OpenAI 有 Function Calling,Anthropic 有 Tool Use,LangChain 又自己封装一套,你在一个框架里写好的工具函数,换一个框架就要重写一遍接口。更麻烦的是上下文——模型要操作文件系统,要么把整个目录列表塞进提示词,要么自己写一套文件读写函数,每个 Agent 的"肌肉记忆"都不一样,维护成本全堆在业务代码里。
MCP 的思路很直接:把工具暴露(Tools)、资源读取(Resources)、提示词模板(Prompts)这三个能力统一成一种协议,客户端(Agent、IDE、桌面应用)和服务端(工具提供方)之间通过标准消息对话。打个比方,以前每个设备都要专用充电线,USB-C 出来之后一根线通吃。MCP 就是模型工具层的 USB-C,模型侧不用关心对面是文件系统、数据库还是 CAD 软件,只要服务端实现了 MCP 协议,客户端就能用统一的方式发现和调用它的能力。
不过 MCP 不是万能的,它不负责模型推理,也不负责 Agent 的工作流编排,它只解决连接问题:模型如何稳定、可控地访问外部能力。把这个边界想清楚,后面做架构设计时就不会把太重的东西堆到 MCP 里。
1.2 一次完整的协议握手,拆开每一步看设计意图
MCP 底层用的是 JSON-RPC 2.0,所有交互都承载在请求(request)、响应(response)、通知(notification)这三种消息里。官方推荐的初始化流程看起来不复杂,但每一步都有明确意图。
客户端连上服务端后,第一条消息必须是 initialize 请求,这是握手的开始:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "my-agent-client", "version": "1.0.0" } } }这个请求里有几个关键字段。protocolVersion 是版本协商的基础,客户端声明自己支持的协议版本,服务端会在响应里选择自己能兼容的版本,避免客户端调用服务端不支持的方法。capabilities 声明客户端能力,比如 roots 表示客户端允许服务端读取根目录信息,sampling 表示客户端允许服务端反向调用模型做采样,这两个能力点决定后续能不能做资源管理和模型二次调用。clientInfo 是身份信息,服务端可以拿它做兼容判断和排障。
服务端收到 initialize 请求后,返回自己的 serverInfo、capabilities 和协商后的 protocolVersion:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "demo-server", "version": "1.0.0" } } }注意,这里客户端还不能立刻调工具。必须先发一个 notifications/initialized 通知,告诉服务端"我已经完成版本协商,可以进入正常消息循环了",然后才能开始 tools/list、tools/call 这些方法调用:
{ "jsonrpc": "2.0", "method": "notifications/initialized" }这套"请求-响应-通知"的时序,保证了双方在能力已知的前提下才交换业务消息,类似两个人见面先确认语言和称呼,再开始聊正事。如果你在自研 MCP Client,最容易踩的坑就是漏掉 initialized 通知,或者跳过 initialize 直接调 tools/list,服务端大概率会拒绝。
1.3 工具发现与调用:tools/list 和 tools/call 的报文结构
理解了初始化,再看工具调用就顺了。tools/list 返回的是工具描述清单,核心是工具名、人类可读的 description 和 JSON Schema 格式的 inputSchema:
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "read_file", "description": "读取指定文件的文本内容,适合读取日志、配置文件等场景", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "description": "要读取的文件绝对路径" } }, "required": ["path"] } } ] } }模型就是靠 description 和 inputSchema 来决定何时调用、传什么参数的,所以工具描述写得好不好,直接决定 Agent 调用准不准。我后面在 LangGraph 部分会再展开聊这个问题。真正执行时走 tools/call,传工具名和参数,服务端返回结构化结果:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "read_file", "arguments": { "path": "/tmp/log.txt" } } }到这一步,一次完整的 MCP 交互就算闭环了。我见过很多团队一上来就写业务代码,对握手过程理解不深,后续排查问题全靠猜,所以这一节的内容建议多看两遍,特别是后面要自己写 Server 或 Client 适配层的人。
2. 传输层与 Server 配置:不只看 command
2.1 stdio 与 Streamable HTTP 的区别
MCP 支持两种主流传输方式,不同方式决定了部署形态和排查思路。stdio 适合本地或容器内进程,客户端直接拉起服务端子进程,通过标准输入输出走 JSON-RPC。Streamable HTTP 适合远程服务,客户端向服务端 URL 发起 HTTP POST 请求,可以做跨机器调用。
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 启动方式 | 客户端直接执行 command,拉起子进程 | 客户端访问 URL,服务端独立部署 |
| 配置要点 | command、args、env | url、headers、鉴权方式 |
| 适用场景 | 桌面应用、本地 Agent、Docker 内 | Web 应用、多客户端共享、远程工具 |
| 生命周期 | 随客户端启动/退出 | 无状态请求,服务端可水平扩展 |
| 常见坑 | 日志打到 stdout 导致协议解析失败 | 路径不对、OAuth 未走完 |
选型时我的建议是:单机、个人工具先上 stdio,简单直接;要给团队用或者让多个 Agent 共享的工具,直接做 Streamable HTTP。远程模式的鉴权和网络环境会更复杂,但换来的是部署灵活,也更容易做统一治理。
stdio 模式下有个特别容易踩的坑:服务端日志千万别往 stdout 打。因为 stdout 是 MCP 消息专用通道,你插一行 print 日志,客户端收到的 JSON-RPC 流就错位了,表现是握手能过但消息解析断断续续,非常难排查。正确做法是日志走 stderr 或文件。
2.2 不同客户端的配置姿势
现在支持 MCP 的客户端越来越多,配置位置和格式各有差异。Claude 桌面版用 claude_desktop_config.json,CherryStudio 有图形化的 MCP 服务器管理界面,Dify 在工具页签里支持添加自定义 MCP 工具,Cursor 可以在项目下放 .mcp/mcp.json,Codex 则通过 config.toml 配置。配置格式大同小异,核心就是声明每个 Server 的命令和参数,或者远程地址和鉴权头。
{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/mcp-storage"], "env": { "READ_ONLY": "true" } }, "database": { "url": "https://your-gateway.example.com/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } } } }这里有个实际的工程取舍。我习惯把全局配置和个人常用工具放一起,项目配置则跟代码提交,方便团队协作时统一工具链。敏感密钥不要直接写进 JSON,优先用环境变量占位注入,避免配置随仓库泄露。CherryStudio 这类图形化客户端对多个 Server 的启停管理比命令行方便,适合做工具验证和调试。Dify 里配 MCP 有一个好处:它会把 MCP 工具暴露成平台的标准工具,工作流节点里可以直接引用,不用自己写代码。Codex 接入蓝湖、Figma 这类设计工具的 MCP 时,授权流程要单独走一遍,我后面在排查章节会讲。
2.3 认证与授权:远程 MCP 躲不开的问题
远程 MCP 的鉴权基本是承接 OAuth 2.0 体系,常见的是 Authorization Code + PKCE,或者直接用静态 Token 做 Bearer 认证。像 Figma MCP 这种需要读写用户数据的服务,核心流程是:客户端先跳转到授权页,用户同意后服务端颁发 access token,客户端在后续 MCP 请求里带上 Authorization 头。
我在接入这类远程 Server 时有一个经验:先把 token 拿下来,用 curl 或者 Postman 手工调一次 tools/list,确认鉴权链路是通的,再配置到客户端里。很多人习惯直接把 Server 填进 Codex 或 CherryStudio,结果界面上显示"已连接",但 Agent 就是找不到工具,排查半天发现是授权步骤根本没走完。授权没闭环时,服务端不返回工具列表,但部分客户端又不给明显的报错,这是最坑的。
3. LangGraph 多 Server 调用:从 Demo 到工程化
3.1 用 langchain-mcp-adapters 把 MCP 工具拉进 LangGraph
LangChain 官方提供了一个适配层:langchain-mcp-adapters,它解决的是"把 MCP Server 暴露的工具变成 LangChain Tool"的桥接问题。你不需要自己维护 JSON-RPC 会话、重连逻辑和工具 schema 转换,适配器封装了客户端生命周期。
下面的示例挂了两个 Server,一个走 stdio,一个走 HTTP,MultiServerMCPClient 会统一回收成工具列表,再交给 langgraph.prebuilt 的 create_react_agent 构造 ReAct 循环:
import asyncio from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent model = ChatOpenAI(model="gpt-4o", temperature=0) async def main(): async with MultiServerMCPClient( { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/mcp-storage"], "transport": "stdio", }, "fetch": { "url": "https://your-gateway.example.com/mcp", "headers": {"Authorization": "Bearer <token>"}, "transport": "http", }, } ) as client: tools = await client.get_tools() agent = create_react_agent(model, tools) result = await agent.ainvoke({"messages": [("user", "帮我列出 /tmp/mcp-storage 下的文件,并读取第一个文件的内容")]}) print(result["messages"][-1].content) asyncio.run(main())create_react_agent 会自动构造"模型选工具 → ToolNode 执行工具 → 结果回填给模型"的循环。对大多数场景来说,这个 prebuilt agent 够用了,但如果你要做细粒度控制,比如多 Server 路由、权限检查、人工审批节点,就需要自己用 StateGraph 搭了。
3.2 多 Server 的命名冲突与路由设计
Server 数量上来了,第一个要面对的是工具重名。文件系统 Server 可能有 read_file,数据库 Server 也可能有 read_record 之类接近的名字,更极端的情况是两个不同厂商的 Server 都叫 get_status。MCP 协议本身没有强制全局唯一,所以适配层在转换工具时通常会带上 Server 名前缀,比如 filesystem_read_file、fetch_get_url。虽然丑,但这是必要的——Agent 调用时才能精确定位,日志里也好追踪。
比命名冲突更值得投入精力的是路由设计。不是每个请求都需要把全部工具暴露给模型,工具铺得越多,模型的选择难度越大,上下文被工具描述占用的比例也越高。两个思路可以结合使用:
第一个思路是按意图分流。在 LangGraph 里加一个 router 节点,根据用户最近的提问判断走哪个"工具子集"节点。比如提到"文件""读取"就走文件 Agent,提到"URL""网页"就走 fetch Agent,其他走默认 Agent。这样每个 Agent 拿到的工具列表更聚焦,决策准确率显著提高。示例:
from typing import TypedDict, Annotated from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] route: str def router_node(state: AgentState): text = state["messages"][-1].content.lower() if "文件" in text or "读取" in text: return {"route": "fs"} elif "网页" in text or "url" in text: return {"route": "fetch"} return {"route": "general"} graph = StateGraph(AgentState) graph.add_node("router", router_node) graph.add_node("fs_agent", fs_agent_node) graph.add_node("fetch_agent", fetch_agent_node) graph.add_conditional_edges("router", lambda s: s["route"], { "fs": "fs_agent", "fetch": "fetch_agent", "general": "general_agent" }) # 各 agent 节点处理完后连回路由以便多轮追问第二个思路是动态裁剪。高频工具常驻模型上下文,低频工具按需加载。LangGraph 节点是 Python 函数,工具列表在节点内是可变数据,所以完全可以在节点里根据状态动态拼接工具列表,再传给下一次模型调用。MCP Server 侧也可以做拆分设计:不要把一个 Server 暴露几十个工具,而是按业务域拆成多个 Server,让 Agent 按需连接。
3.3 工具描述与上下文预算
工具描述会完整塞进模型上下文,而且每轮 Agent 循环都会重复计算。如果一个 Server 暴露 30 个工具,每个工具描述 200 字,那就是 6000 字,这在上下文里是一笔不小的开销。我做过一次统计,工具描述在输入 token 里的占比,在某些复杂 Agent 里能到 40% 以上。这意味着真正给业务上下文的空间被压缩了,还会影响模型对工具的判断。
我的处理习惯是定期用 profiling 统计每次 Agent 请求的输入 token 结构。如果工具描述占比过高,优先精简 description,而不是加模型长度。写工具描述时把"必填参数、默认行为、典型用例"三件事讲清楚就够了,别写作文。给一个简单的命令示例字符串,往往比三段式的散文对模型更有效。另外,inputSchema 里字段的 description 同样会影响模型填参准确性,尤其是枚举值,直接把可选项写进描述比只给一个 enum 数组更稳。
4. 常见问题与排查技巧实录
4.1 工具列表拿到了,调用却报参数错误
这个问题我遇到太多次了。现象是 tools/list 正常,工具在 Agent 决策里被选中了,但 tools/call 返回 invalid params。原因通常是服务端对参数校验严格,而模型传了多余字段、缺少必填字段,或者类型对不上——JSON Schema 里标注了 string,模型传了数组或数字。这本质上是 schema 设计和模型能力之间的差距问题。
排查方法很朴素:先把模型输出 log 打开,看 tool call 的具体 payload,再对照 inputSchema 逐字段核对。定位之后,常见的补救方式有三个:一是优化 inputSchema,把必填字段放到 description 里强调;二是在适配层加一个参数清洗函数,对常见类型偏差做兜底;三是在系统提示词里给一段工具调用的 few-shot 示例。第三点对 gpt-4o 这类模型效果明显,但对小参数模型不一定有用,还是要回到 schema 设计上来。
4.2 握手过不了的几个典型卡点
握手阶段出问题,建议按下面的顺序排查。卡在 initialize 阶段,服务端日志显示没收到请求,多半是 stdio 模式下子进程启动失败了——npx 没装、路径写错、Node 版本太低,或者命令里带了不该带的 sudo。先在命令行手工跑一遍 command 和 args,确认能正常输出 JSON-RPC 消息,再回客户端配置。
HTTP 模式卡在握手,先看 URL 路径是不是对的。很多 Server 不是根路径,而是 /mcp 或 /api/v1/mcp,路径写错会返回 404 或 405。另外一个高频问题是协议版本不匹配,老 Server 可能只支持 2024-11-05,新客户端默认发 2025-06-18 就会被拒,客户端里配置成动态协商协议版本,或者干脆和服务端确认支持哪个版本。最后还有一类是代理和网关层改了请求体,MCP 对 Content-Type 和 JSON-RPC 结构敏感,反向代理别自作主张改写 body。
4.3 认证授权没走完,Codex 和 CherryStudio 表现不一样
典型场景是 Codex 里配置了远程 MCP Server,但 Agent 一直说找不到工具。我一查,授权流程没走完。Figma MCP 这类服务要求用户在浏览器里完成 OAuth 授权,客户端拿到 token 后才能拉取工具列表。如果你只是从配置模板里拷了一个未授权的 Server 地址,界面上看着加进去了,实际 tool 列表是空的。
CherryStudio 和 Dify 的表现稍微好一点,通常在添加 Server 后会有明确的授权按钮,但 Codex 的交互里授权步骤容易忽略。解决思路是:确认当前登录的用户身份、重新走一遍授权流程、验证 token 有没有过期,然后抓一次 HTTP 请求看 Authorization 头是否带上了。这里的核心教训是——认证配置一定要验证"工具列表能拉回来",只有连接状态显示"已连接"是不够的。
4.4 流式输出到文件的性能坑
CherryStudio 这类客户端里经常有人问"MCP 工具怎么流式输出内容到文件"。我理解这个需求的本质是:Agent 在持续生成内容,但同时要把内容写入本地文件、日志或数据库。这里有个容易被低估的坑——MCP 工具调用对模型来说是一个同步操作,如果你把"写一个大文件"当成单个工具调用,服务端要等全部写盘完成才返回结果,模型侧表现就是工具调用长时间挂起,最终可能超时。
更好的方案是把写文件设计成三步式工具:打开文件流、追加内容、关闭文件流。或者更工程化一点,MCP Server 先接收内容并快速返回一个任务 ID,实际写盘在后台异步执行,客户端轮询任务状态。我看过几个重 I/O 的 MCP Server 实现,基本都是"任务 ID + 异步回执 + 状态查询"这套模式,体验比一次性写完整文件好得多。如果你只在本地用一个临时工具,直接三步式最省事,别在这个场景上追求过度设计。
4.5 问题速查表
| 现象 | 排查方向 | 最快解法 |
|---|---|---|
| 握手卡住,服务端无日志 | stdio 子进程未启动 | 命令行手动跑 command 验证 |
| HTTP 返回 404/405 | Server 路径不对 | 确认实际挂载路径(/mcp 等) |
| 工具列表为空 | 认证授权未完成 | 重新走完整 OAuth 流程 |
| 工具调用报 invalid params | 模型参数与 schema 不符 | 打开 tool call payload 日志,逐字段核对 |
| Agent 找不到已配置的 Server | config 作用域不对 | 区分全局配置和项目配置 |
| 长文件写入超时 | 工具设计为一次性同步写盘 | 改成三步式或异步任务模式 |
5. 一点体会与后续扩展
说实话,MCP 的上手门槛不低,既要求理解协议语义,又要懂工具 schema 设计,还要会搭 Agent 框架。但一旦把工具层切到协议标准上,后面扩展 Agent 能力会省非常多事。我现在的几个生产项目,外部能力全部收敛进 MCP Server,LangGraph 只负责工作流编排和状态管理,模型只管决策和工具选型,边界非常清楚。
最后分享一个经验:新接入一个 MCP Server 时,别急着上生产。先在本地用最小脚本把三件事跑通——握手成功、tools/list 能看到工具、tools/call 能返回预期结果。三件事全绿再放进 LangGraph,不然排查时根本分不清是协议问题、工具问题还是 Agent 路由问题。我在实际项目里还发现,先从一个高频小工具入手跑通全链路,比一次性接十个 Server 再慢慢调要高效得多,这个顺序反过来走会很痛苦。