
MCP这三个字母近半年在AI工程圈子里出现的频率越来越高。如果你也和我一样忙着把各种工具接入到Agent里大概率会对这东西既兴奋又头疼兴奋的是它把Tool Use标准化了头疼的是一旦涉及的Server多起来协议握手、工具发现、多服务路由这些问题会一起砸过来。这篇文章不聊概念和PPT把我从协议握手机制开始到把多个MCP Server接进LangGraph流程的全部过程拆开讲一遍能帮你少踩几个坑。先说结论MCP本身并不复杂核心就是一个基于JSON-RPC 2.0的消息协议难的是接入真实业务时那些边界情况。比如同一个工具名在不同Server里语义不同、某个Server返回了超大报文、异步工具在LangGraph里没有正确包装导致直接报错。这些东西不实际跑一遍光看官方文档是学不来的。下文按照我自己的实践顺序展开先搞清楚协议为什么这么设计再手写一个最小Server最后把多个Server接进LangGraph的Graph里做动态路由。每一步都会带上我踩过的坑和最终的解决方案。1. 先弄清楚MCP到底解决什么问题1.1 工具泛滥之后的连接烦恼过去两年各家AI应用都在做Agent能力但每个应用接入工具的方式几乎都不一样。有些暴露OpenAPI有些走Function Calling有些自己定义了一套插件协议。作为接入方我们不得不在每个项目里写一套适配层调这个服务用这种认证调那个服务用那种参数格式返回结果还得自己再清洗一遍。换个模型可能前面所有的适配代码又要重写。MCP的全称是Model Context Protocol最初由Anthropic在2024年底提出并开源目标是给LLM应用和外部工具之间定义一个统一的消息协议。你可以把它理解为“AI世界的USB-C接口”一端连接模型和应用框架一端连接数据库、文档、开发工具、设计软件甚至ERP系统双方只要按同一套协议说话就能互相通信。早期接受MCP的软件大多是命令行类工具现在已经扩展到Altium Designer、IDA、x32dbg这类专业软件说明这个协议确实在快速落地。1.2 MCP的核心设计思路MCP采用客户端-服务端架构。MCP Server暴露能力MCP Client负责连接和调用。协议底层基于JSON-RPC 2.0传输层默认支持stdio和Streamable HTTP两种方式。stdio适合本地进程间通信比如你在Neovim里启动一个本地MCP ServerHTTP适合远程服务比如团队内部部署的工具网关。协议中最重要的几个概念是Tools、Resources、Prompts。Tools就是让模型可以调用的函数有输入参数和返回值Resources是用来读取上下文的数据源比如文件、配置、日志Prompts是预置的提示词模板用来辅助完成特定任务。对于大多数实际项目Tools是最常用、也是价值最高的部分所以下面的内容会聚焦在工具调用上。1.3 从Protocol到Tool的映射逻辑MCP协议在设计上做了两层隔离。第一层是消息格式隔离用统一的JSON-RPC封装所有请求和响应第二层是能力映射隔离Server声明自己提供什么能力Client再决定怎么把这些能力暴露给模型。好处很明显模型层不用关心工具背后的具体实现是HTTP调用还是读数据库对LLM来说都只是一个名字加一段JSON Schema。理解这个映射逻辑对接下来的握手过程就容易多了。Claude、LangChain这类框架接入MCP时本质上就是把MCP返回的tool schema转换成模型平台认识的Function Schema模型决定调用某个工具后再由框架把参数回传给MCP Server执行。这里每一步都有SQL注入般的“细节魔鬼”参数类型校验、JSON序列化、超时控制任何一个地方没做好都会导致工具调用莫名其妙失败。2. 协议握手从HTTP GET到initialize的完整链路2.1 为什么要先握一次手很多第一次接触MCP的朋友都会问调用工具为什么不能像REST接口一样直接发个POST请求简单直接原因是要协商能力。MCP支持很多扩展能力比如资源订阅、日志回调、采样但并不是每个Server都实现了全部特性。Client需要知道Server支持哪个协议版本、支持哪些能力、工具列表怎么获取才能决定后续用什么样的方式通信。握手的过程其实很像两个人初次见面交换名片先确认彼此用什么语言沟通再说明自己能干什么最后才进入正式业务。如果跳过协商直接调用工具很容易出现协议版本不匹配、Server不认可Client的初始化请求这类问题。在实际对接中我看到过不少因为跳过握手导致的401错误其实不是权限问题而是协议协商失败。2.2 握手流程分步拆解一个标准的HTTP MCP交互大致分成三个阶段服务发现Client先请求GET /Server返回一个JSON对象包含支持的协议版本、能力列表和Server名称。这一步用于发现端点和基础信息不算正式的握手。初始化协商Client发送initialize请求带上自己支持的协议版本和clientInfo。Server从中选择双方都能接受的最新协议版本返回serverInfo和capabilities。初始化确认Client再发送一个notifications/initialized通知告诉Server“我已经知道你支持什么了咱们正式开始”。之后Client就可以继续调用tools/list和tools/call。第三步很容易被忽略有些人以为initialize之后就完事了直接发tools/call结果服务端返回错误。原因就是MCP要求初始化之后必须发送initialized通知否则Server不会进入就绪状态。这一点在官方规范里写得很清楚但实际报错时信息可能很隐晦我排查过几次最终都是通过抓包才确认是少发了这个通知。2.3 协议版本、能力协商与服务端声明协议版本协商是握手里最值得注意的环节。Client在initialize请求里会带上自己用的协议版本Server拿到之后必须回一个双方都支持的版本。多数实现会选择Client发来的版本号如果Server检测到Client版本比自己高按规范也可以降级处理。目前最常见的标准版本是2024-11-05和2025-03-26我在文章后面的示例中会统一使用2025-03-26。能力协商这块Server用capabilities字段告诉Client自己能做什么。重点看三个子字段tools表示是否支持工具调用resources表示是否支持资源读取prompts表示是否支持提示词模板。我一般在自研Server里先只声明tools等后续真有需要再扩展resources这样排查问题面最小。服务端声明信息由serverInfo提供包含name和version。别小看这段信息很多框架日志里用它来标识工具来源。在LangGraph里做多Server调用时如果每个Server的name都起成“demo-server”日志里会根本分不清工具是谁提供的。我习惯命名成“kb-server”“sql-server”“ticket-server”这种带业务含义的名字。2.4 一次真实的握手报文参考这里给一个我在调试时实际抓到的简化报文方便你对协议有个直观感受# 请求GET / { jsonrpc: 2.0, method: GET }实际上GET /返回的不是JSON-RPC格式而是一个HTTP响应{ protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true } }, serverInfo: { name: kb-server, version: 0.1.0 } }然后POST /mcp进入JSON-RPC交互{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-03-26,capabilities:{tools:{}},clientInfo:{name:langgraph-client,version:0.1.0}}}响应{jsonrpc:2.0,id:1,result:{protocolVersion:2025-03-26,capabilities:{tools:{listChanged:true}},serverInfo:{name:kb-server,version:0.1.0}}}最后发通知{jsonrpc:2.0,method:notifications/initialized}实际开发中很少有人手写这些JSON都直接用SDK。但理解这个流程对排查问题特别关键比如Service端返回“Unsupported protocol version”时你至少能立刻猜到是版本协商出了问题而不是无意义地重启服务。3. 动手写一个最小MCP Server接线比想象中简单3.1 技术选型与脚手架我推荐用官方Python SDK实现Server因为库比较成熟示例也多。安装很简单pip install mcp fastapi uvicorn我习惯用FastAPI加Streamable HTTP传输这样调试方便也方便后续部署到内网机器上。SDK的FastMCP类可以直接把函数变成工具开发体验非常顺几行代码就能跑起来一个Server。from mcp.server.fastmcp import FastMCP mcp FastMCP( kb-server, instructions知识库查询服务提供文档检索和摘要能力。, ) mcp.tool() def search_docs(keyword: str, limit: int 5) - list[dict]: 根据关键词检索内部知识库文档。 # 示例返回真实项目中这里会调向量库或ES return [ {title: MCP协议说明, score: 0.95}, {title: LangGraph接入指南, score: 0.88}, ] if __name__ __main__: mcp.run(transportstreamable-http)上面只是一个最小例子先感受一下工具注册的方式。注意函数注释非常重要因为MCP会把函数名和docstring直接转成模型的工具描述注释写得越清楚模型在调用时就越不容易选错。我见过不少团队把工具注释写成“查询数据”这种空话结果模型根本不知道什么时候该调这个工具。3.2 注册工具与处理业务回调FastMCP最方便的地方在于把参数类型直接映射为JSON Schema。但这里有几个坑需要说明参数类型必须精准。建议用具体类型比如int、str不要图省事全写成Any否则LLM传参时很容易乱传类型服务端强转又会报错。返回值必须是可序列化的。MCP走的是JSON传输datetime、Decimal这类对象直接返回会炸我习惯在返回前统一做一次序列化。工具内部最好自己捕获异常把错误信息作为可读文本返回。LLM看到错误文本之后还能根据信息自我修正重新调用但如果整个调用直接抛异常框架层可能直接终止这个分支的执行链。以业务回调为例一个内部工单查询的Server可以写成这样mcp.tool() def get_ticket(ticket_id: str) - dict: 根据工单号查询工单状态和优先级。 try: row db.query_ticket(ticket_id) # 伪代码 if row is None: return {found: False, message: f未找到工单 {ticket_id}} return {found: True, ticket_id: ticket_id, status: row.status, priority: row.priority} except Exception as e: return {found: False, message: f查询工单失败: {str(e)}}这样写的好处是模型拿到的结果永远是结构化的不会因为一个异常就断了整个Agent的思考链路。3.3 本地连调MCP Inspector与Client验证写完Server后我强烈建议先别急着接LangGraph先用官方MCP Inspector把Server跑通确认工具能正常发现和调用。Inspector是最直观的调试界面可以查看工具列表、手动调用工具、查看请求响应日志。启动方式mcp dev server.pyInspector的本意是本地调试它启动时会自动加载你的server.py并提供一个Web页面。我常用的调试顺序是先看初始化握手是否成功确认协议版本没有报错。打开Tools列表检查工具名和参数Schema是否和预期一致。手动调用一次工具验证返回结构。如果这一步发现工具列表是空的最常见的两个原因一是函数没有加mcp.tool()装饰器二是装饰器加了但函数定义时抛了异常SDK静默吞掉了。第二种情况在真实项目中发生过不止一次排查时要留意服务端日志不要只盯着客户端。3.4 权限与安全边界本地Server怎么玩都行一旦要部署到服务器上权限问题必须重视。MCP本质上给LLM发了一张“万能卡”如果Server里面暴露了删库、改配置、发邮件这种高风险操作模型一旦被Prompt注入诱导后果可能很严重。我的经验是工具默认只读写操作单独设计。高危工具在参数里加一重确认字段比如confirm: bool防止误调用。Server内部做好固定Token校验不要把密钥硬编码在返回报文里。日志里脱敏任何工具入参和返回值都不打全量只打关键字段。安全这块没有银弹但要记住一条原则LLM能调用什么等价于攻击者能通过LLM调用什么。MCP帮你省了适配成本省不了的是安全审查。4. LangGraph多Server调用架构设计与状态流转4.1 为什么用LangGraph而不自己写if-else单Server的场景还比较简单直接连上就完事。真正让人头疼的是多Server知识库一个、数据库一个、工单系统一个也许还有外部系统一个。如果靠手写if-else做工具分发代码会快速膨胀而且每加一个Server就要改一次路由逻辑。模型能力的强弱也会直接影响分发效果根本没法维护。LangGraph的好处是把Agent执行流程建模成一张图节点表示计算步骤边表示流转条件状态在节点之间显式传递。你可以把路由逻辑交给LLM也可以自己写规则节点还可以做子图复用。对多Server场景来说最合适的方式是每个Server对应一个子图或工具节点由一个路由节点决定当前任务应该进入哪个Server。这样Server之间互不干扰单个Server挂掉不会影响全局。4.2 多Server调用的整体架构我用的架构分四层入口层接收用户输入组装初始状态。路由层用LLM分析意图从Server列表里选目标这一步不是直接返回工具参数而是决定进入哪一个Server子图。执行层每个Server对应一个子图。子图内部有独立的工具调用节点和结果处理节点。汇总层收集所有执行结果生成最终回复。这里最重要的一点是不要让模型在路由层就一次性看到所有Server的所有工具。比如一共有20个工具全部塞给模型当Function列表不仅浪费token而且会严重干扰选择准确率。应该先在路由层做粗粒度筛选再进入具体Server在这个Server里模型才看到精细的工具列表。这种两级分发结构我实测下来工具命中率能提升不少。4.3 基于LangGraph编排的思路用LangGraph实现时核心是状态定义和节点切换。一个简化版本的状态长这样from typing import TypedDict, Annotated from langgraph.graph import StateGraph, START, END from langgraph.prebuilt import ToolNode class AgentState(TypedDict): messages: Annotated[list, add_messages] user_input: str target_server: str tool_results: dict然后定义几个节点router_node负责根据用户输入选择Serverkb_node执行知识库工具sql_node执行数据库工具summary_node最终汇总。def router_node(state: AgentState): # 这里用LLM结构化输出或简单的关键词规则 text state[user_input] if 工单 in text or 故障 in text: target ticket elif 知识 in text or 文档 in text: target kb else: target sql return {target_server: target} workflow StateGraph(AgentState) workflow.add_node(router, router_node) workflow.add_node(kb, kb_executor) workflow.add_node(sql, sql_executor) workflow.add_node(ticket, ticket_executor) workflow.add_node(summary, summary_node) workflow.add_edge(START, router) workflow.add_conditional_edges( router, lambda state: state[target_server], {kb: kb, sql: sql, ticket: ticket} ) workflow.add_edge(kb, summary) workflow.add_edge(sql, summary) workflow.add_edge(ticket, summary) workflow.add_edge(summary, END)真实项目里我会用LLM替代这个简单的关键词路由因为用户表达方式千奇百怪“查一下单子走到哪儿了”这种说法关键词规则基本命中不了。但在稳定性和调试简易性上规则路由其实更可控。我建议前期先用规则跑通整个链路再换LLM路由两个阶段的问题能分离排查起来清楚很多。4.4 延迟与容错取舍多Server调用的延迟问题比单Server明显很多。每个Server握手一次、拉取工具列表一次、调用一次再加上LLM的两次决策一趟下来可能十几秒。我的做法是连接复用MCP Client复用HTTP会话不要每次都重新握手。工具列表缓存Server工具列表大多稳定不变在Client端缓存起来按需刷新而不是每次会话都重新拉取。超时拆分给路由LLM、Server调用分别设置超时时间不要把整个Agent流程塞进一个超时里不然定位问题无从下手。并发执行如果多个Server之间没有依赖关系可以用async并行调用。但注意LangGraph的节点默认是串行执行的需要手动改成Async节点或者用langgraph的SendAPI做动态扇出。容错方面我习惯每个Server子图外面包一层异常捕获如果某个Server超时或直接连不上不要中断整个Graph而是返回一个结构化的错误信息让汇总节点决定怎么给用户解释。这比让整个流程崩溃再重试要体面得多。5. 多Server接入实操从工具发现到动态路由5.1 在LangGraph里挂载多个MCP Client接入LangGraph时最直接的方式是使用langchain-mcp-adapters它能把MCP Server暴露的工具列表转换成LangChain的Tool对象然后LangGraph的ToolNode就能直接执行。安装命令pip install langchain-mcp-adapters langgraph下面这段是我实际项目里的简化示例演示怎么连接多个Serverfrom contextlib import asynccontextmanager from langchain_mcp_adapters.client import MultiServerMCPClient asynccontextmanager async def connect_servers(): async with MultiServerMCPClient( { kb: { url: http://localhost:8001/mcp, transport: streamable-http, headers: {Authorization: Bearer token}, }, sql: { url: http://localhost:8002/mcp, transport: streamable-http, }, ticket: { url: http://localhost:8003/mcp, transport: streamable-http, }, } ) as client: tools await client.get_tools() yield tools这里有几个细节值得注意。MultiServerMCPClient返回的工具名默认会带上Server前缀比如kb_search_docs、sql_get_table_list这是LangChain适配器为了避免工具重名做的处理。我建议这个前缀保留不要为了好看去重命名因为多Server场景下工具名必须全局唯一。headers参数用来传认证信息如果你的Server在公司内网通常需要在这里加一个服务网关签发的Token。5.2 让LLM按需选择Server工具挂载好之后还要解决怎么让模型选择正确Server的问题。LangGraph里我通常把路由逻辑放在一个Router节点里用一个单独的LLM调用完成选择不把这个任务混进最终答案生成里。原因是路由决策和答案生成是两件事合并在一起时模型往往只顾着生成答案忽略了工具调用。路由节点大致长这样from pydantic import BaseModel from langchain_openai import ChatOpenAI class RouteDecision(BaseModel): server: str reason: str def create_router(): llm ChatOpenAI(modelgpt-4o-mini, temperature0) def router(state): prompt f根据用户问题选择最合适的工具服务。 可选服务 - kb知识库文档检索 - sql数据库表查询 - ticket工单系统查询 只返回一个服务名。用户问题{state[user_input]} decision llm.with_structured_output(RouteDecision).invoke(prompt) return {target_server: decision.server} return router注意这里我用的是with_structured_output让模型直接返回结构化字段。这种方式比让模型从自由文本里生成服务名要稳得多因为自由文本可能带标点、额外解释后面条件路由判断时容易踩坑。5.3 参数映射与上下文传递多Server调用一个很隐蔽的问题是参数错位。不同Server对同一个概念可能字段名完全不同比如知识库服务叫keyword、数据库服务叫query、工单服务叫ticket_id。如果路由节点只返回一个Server名称没有把用户原始输入一起传下去执行节点就会不知道用什么参数。我的做法是在State里保留一份user_input原文执行节点做一次简单的参数规整def sql_executor(state): query_text state[user_input] tools {tool.name: tool for tool in state[available_tools] if tool.name.startswith(sql_)} result tools[sql_query].invoke({sql: query_text}) return {tool_results: {sql: result}}这个过程看起来简单却是我调试多Server时花时间最多的地方。LangGraph的State是全局共享的如果你在执行节点里意外覆盖了messages字段后面的节点可能会失效。所以给State的关键字段起名时要刻意区分user_input就是用户原话tool_results就是各Server返回结果字典不要混用。5.4 流式输出改造MCP Server返回的结果通常是完整JSON但用户在前端希望看到类似ChatGPT的逐字输出。这个差异会直接影响体验。LangGraph本身支持流式返回关键在于Agent的LLM节点回调粒度。我的方案是把流式事件按类型分开处理工具调用阶段返回结构化事件最终生成阶段返回token级别事件。async for event in graph.astream_events(initial_state, versionv2): kind event[event] if kind on_chat_model_stream: token event[data][chunk].text yield fdata: {token}\n\n elif kind on_tool_start: yield fdata: [tool] {event[name]}\n\n elif kind on_tool_end: output event[data][output] yield fdata: [tool_done] {str(output)[:200]}\n\n这个改法的核心是不要让LangGraph帮你过滤事件类型自己在客户端处理。工具执行的中间过程要不要展示给用户完全看你的产品设计。我一般会展示工具名和简短的进度说明但不会把完整工具结果全部塞给前端一方面是安全另一方面是信息噪音太大。6. 常见问题与排查技巧实录6.1 握手成功但工具调用失败这是我遇到最多的一个问题现象是initialize一切正常tools/list也正常但真正tools/call时Server端一直报错。排查时先看两点参数是否合法。很多Server在tools/list里声明参数必填但客户端传的时候用了错误类型。比如参数声明是string客户端传了数组Server端解析JSON Schema严格时会直接报INVALID_PARAMS。是否缺少notifications/initialized通知。部分SDK的实现比较老不在握手阶段做严格检查但调用工具时校验状态这时候会在服务端日志里看到“Server not initialized”之类的记录。我通常会在MCP Client侧记录下每次请求的原始报文排查时先对比tools/list返回的Schema和tools/call传入的参数大多数参数问题一眼就能看出来。6.2 多Server下工具同名冲突两个Server里都有search这个工具名多Server Client挂载后就会出现后者覆盖前者的现象。LangChain适配器会加前缀但如果你自己封装Client必须自己处理唯一性。我的建议是无论用什么框架都坚持两段式命名server名_工具名。比如kb_search_docs、sql_search_tables。这样既保证唯一又能在调用失败时快速定位是哪个Server出了问题。工具重名还有一个隐患模型会混淆两个同名工具的用途。即使你加了前缀也要在工具描述里明确写清楚“这个工具属于知识库搜索内部文档”不要指望前缀就够了。6.3 身份认证与Token传递MCP Server可以自行定义认证方式常见的有Bearer Token和OAuth2。LangGraph的空格——不是LangGraph连接时通过Client的headers参数传递。但要注意一个细节initialize阶段和tools/call阶段的Token过期时间可能不同如果Token有效期比较短长任务跑到一半可能会401。解决方法是客户端维持一个定时刷新Token的机制或者在服务端做一次Token校验的宽松处理只验证用户存在不验证过期时间内网环境我倾向于后者。6.4 资源占用和长连接MCP Server如果大量用stdio方式启动每个Client会话都会拉起一个子进程资源开销很高。我在本地调试时开过五个Server结果机器内存直接吃紧。后续改成Streamable HTTP方式后服务端统一常驻Client只维持一个HTTP连接池资源占用显著下降。如果工具调用非常频繁建议给Server做进程内缓存避免同一个查询被重复执行比如按用户问题做短时哈希缓存几十秒内相同请求直接返回缓存能缓解不少压力。下面把高频问题整理成一个速查表方便你后续排查时快速对照。现象可能原因处理方式initialize报Unsupported protocol version客户端版本与服务端版本不匹配统一协议版本服务端做降级兼容工具列表为空函数没有注册装饰器或Server内部异常检查装饰器查看Server端日志调用工具时返回INVALID_PARAMS参数类型与Schema不一致对照tools/list返回的Schema逐字段核对多Server工具互相覆盖工具名没有唯一前缀手工加前缀禁止裸工具名长任务中途401Token过期客户端定时刷新Token或服务端放宽校验输出流卡顿SSE缓冲区未刷新调整Server的缓冲策略关闭Nginx的proxy_buffering7. 一些调试习惯和后续想捣鼓的方向最后分享几个我自己的调试习惯不一定适用于所有人但至少帮我省了很多时间。第一所有MCP相关请求响应都留原始日志哪怕只是打到本地文件都对接下来的问题排查有巨大帮助。协议层面的问题靠推理很难定位日志一拉请求和响应一对比90%的问题都能看出来。第二每接入一个新Server先只连它自己跑一条最简单链路再把链路逐步拉长到完整Agent流程而不是一次性把五个Server全挂上去调试。第三LLM路由节点的输出一定要做结构化约束别让模型自由发挥。关于后续扩展我打算把Resources能力用起来让Agent在调用工具前先读取一批系统上下文减少无效调用另一方面想把多Server的鉴权逻辑统一收敛到一个网关层而不是每个服务各自处理Token。MCP生态还在快速演进协议版本一更新很多旧代码可能要跟着调整。我的建议是不要把协议封装写得太深保持薄薄的一层让它随时可以替换。技术选型上优先跟着官方SDK走因为它通常会最早适配新版本。踩过几次坑之后我个人体会很深MCP本身不解决业务逻辑它只是帮你把工具接进来真正让Agent“下地干活”的还是你对他背后业务边界的理解以及一套能兜底的容错机制。