如果说 2025 年上半年最让我觉得“终于有统一的东西了”的技术,那一定是 MCP。这次项目我们用了三周,把一个跨栈的内部系统完整接入 MCP:服务端有 Python、Node.js,还有负责订单的老 Java 服务,客户端既有 Claude Desktop,也有自研 Agent 和 Cursor。整个流程从需求澄清、方案选型,到写 MCP Server、配客户端,再到端到端验证,每一步都踩过不同级别的坑。这篇复盘把关键决策、踩坑细节和验证方法都摊开讲,如果你是正在做 MCP 接入的开发者,或者团队里有人在规划 Agent 工具层,应该能少走不少弯路。
1. 需求澄清阶段:别急着写代码,先把“为什么”谈清楚
项目启动第一天,业务方给的需求就一句话:“我们要上 MCP,让 AI 助手能查订单、建工单。”说实话,这句话和信息没区别。真正花时间的是把“MCP 到底解决我们什么问题”和“接入边界在哪里”聊透。
1.1 MCP 是什么,为什么这次选它而不是其他方案
先把概念对齐。MCP 全称是 Model Context Protocol,它解决的痛点是:大模型应用想调用外部工具和数据,但每一个客户端、每一套 Agent 框架都有自己的工具调用方式,导致“做一次集成,绑死一个平台”。
打个比方,MCP 就是 AI 应用和外部工具之间的 USB-C 接口。客户端(Claude Desktop、Cursor、自研 Agent)相当于电脑,MCP Server 相当于显示器、键盘、读卡器。只要大家都遵循同一个接口标准,显示器不用关心你用的是 Mac 还是 Windows,电脑也不用为每一种外设单独定制连接线。
当时我们内部有过一次方案 PK,摆在桌面上的选项有三个:
- 直接让大模型调用内部 REST API。这方案看起来最直接,但问题也最明显。内部系统有几十个接口,参数格式、鉴权方式、错误码各不相同。直接暴露给模型,意味着要把所有调用细节写进 prompt 里让模型“记住”,一旦接口格式调整,prompt 就要重写,成功率还完全依赖提示词质量。
- 用 LangChain / LlamaIndex 的 tool 机制。这套机制本身不差,工具定义、参数校验都有现成的实现,但它和框架深度绑定。今天我们用的是 LangChain,明天想换成别的 Agent 框架,工具层就得全部重来,维护成本会一直滚下去。
- 引入 MCP。MCP 把“工具描述、参数 JSON Schema、调用逻辑”从应用层解耦出来。一份 MCP Server 写好,Claude Desktop 能用,自研 Agent 能用,Cursor 也能用,后续就算要接 Codex 或别的客户端,也只是改配置的事。
我们最后选了 MCP,核心判断就一条:这是一个长期维护的跨栈系统,客户端不是固定的某一家,协议层投资收益比最高。后来这个判断也被验证了——项目进行到一半,客户临时要求加一个 Codex 入口,我们只改了一行配置,MCP Server 代码完全没动。
1.2 跨栈系统的接入边界怎么划
“跨栈”这个前缀,在这里有两层含义:一是技术栈跨语言(Python、Node.js、Java 并存),二是系统边界跨团队(订单服务归 A 组、日志服务归 B 组)。方案澄清阶段最要命的问题,就是把接可边界划清楚。
我们梳理了三种需求类型,分别做了差异化处理:
- 只读查询:比如订单状态、日志检索。这类操作简单、无副作用,适合直接做成 MCP 工具,返回结构化数据给模型。
- 写操作:比如创建工单、触发部署。这类操作涉及权限、幂等性和审计,MCP Server 只做入口,真正的业务逻辑必须落在内部服务里,Server 层绝不允许直接改数据。
- 跨系统编排:比如“查完订单之后自动查物流”。这种我们不打算封装成单一工具,而是让模型通过多轮工具调用来完成,保留 LLM 的编排能力。
边界原则总结成一句话:MCP Server 只做翻译层,不做业务层。所有鉴权、幂等、权限校验都下沉到内部服务,MCP Server 只负责把协议调用翻译成内部 API 调用。这个约定带来两个直接好处:一是 MCP Server 挂了不会影响业务服务;二是内部服务改了接口,只需要改 Server 里的适配逻辑,业务代码零侵入。
2. 技术选型与跨栈设计:先定传输层,再动代码
方案澄清之后进入选型。这一阶段是我们踩坑最密集的区域,尤其是传输层选型,选错了后面全盘难受。
2.1 传输层怎么选:stdio、SSE 还是 streamable HTTP
MCP 目前的传输层主要有三种,我直接按场景做了个对比:
| 传输方式 | 通信机制 | 适用场景 | 主要限制 |
|---|---|---|---|
| stdio | 子进程标准输入输出通信 | 本地 CLI、桌面客户端 | 必须本地有进程,远程完全不可用 |
| SSE | HTTP + Server-Sent Events | 远程服务、老版本客户端兼容 | 部分客户端只支持特定流式写法 |
| streamable HTTP | 单一 HTTP 端点,GET/POST/DELETE | 远程服务、多客户端并发 | 要求 client 和 server 都是较新版本 |
我们的场景很明确:MCP Server 要部署成内部服务,多个客户端并发访问,所以 stdio 直接排除。在 SSE 和 streamable HTTP 之间,我们优先选了 streamable HTTP。原因是它用统一端点收发请求,对反向代理、负载均衡更友好,也是官方当前推荐的方向。SSE 更像是一个过渡方案,除非你的客户端版本太老不支持新协议,否则没必要用。
这里给一个很实在的建议:如果你不是在本机跑 CLI 工具,而是要做一个服务端接入,优先 streamable HTTP。网上大量教程还在用npx -y @modelcontextprotocol/server-everything跑 stdio 示例,那些例子当学习材料可以,上生产环境后必须重新评估 HTTP 传输下的并发和超时行为。
第二点建议是:选型前先确认你的目标客户端支持哪些传输方式。我们当时默认 Claude Desktop 支持 streamable HTTP,实际测试发现某个旧版本只认 SSE,差点在交付前夜翻车。这个事后面我会展开说。
2.2 整体架构:MCP Server 是一个很薄的适配层
我们最终落地的架构大概是这样的:
- 客户端层:Claude Desktop 给内部运营人员用,自研 Python Agent 给自动化流程用,Cursor 给研发团队用。
- MCP Server 层:基于 Python FastAPI 和 FastMCP 实现,暴露三个工具——订单状态查询、工单创建、日志检索。
- 业务服务层:订单服务是 Java 写的,日志服务是 Python 写的,中间通过 HTTP 互相调用。
为什么 MCP Server 这层选 Python?因为当时 FastMCP 的 API 最顺手,工具注册、参数 schema、装饰器写法都足够简洁,代码量最少。内部业务层有 Java 有 Python,跨语言直接走 HTTP,不纠结 RPC 协议。
跨栈场景下有个很重要的隔离意识:MCP Server 不允许直接连数据库,只允许调内部 API。这是我们定的第一道安全边界。原因很朴素:如果哪天一个粗心大意的工具描述让模型把整张表查了一遍,至少还有内部 API 的限流和权限兜底。另一个团队协作层面的好处是,不同团队维护自己的服务,排查问题时不至于把 MCP 协议层的锅甩给业务层。
2.3 工具设计与参数 schema:真正的隐性魔鬼
工具设计是整个项目里最容易被低估的部分。MCP 工具调用靠的是“工具名 + 描述 + 参数 JSON Schema”,这三件事里任何一件写得含糊,模型调用成功率都会肉眼可见地下降。
我们实际踩过的坑,列三个最典型的:
- 工具名别用缩写。一开始我们把订单查询工具命名为
query_ord_info,测试时模型经常把用户“查订单”的意图匹配到别的工具上。改成query_order_status之后,意图识别准确率立刻上来了。模型是看名字猜用途的,你用一个看起来像内部变量名的工具名,它就只能靠猜。 - 参数描述要写人话,枚举值一定要带例子。比如 status 字段,光写“订单状态”是不够的。要写成“订单当前状态,可选值包括:PROCESSING 处理中、COMPLETED 已完成、CANCELLED 已取消”。模型看到“处理中”这种自然语言描述,才知道该传什么值。
- 必填参数必须在 schema 里标 required。我们出过一次线上事故:模型调用工具时没传 order_id,server 直接抛异常。后来在 JSON Schema 的 required 数组里显式声明了 order_id,这类错误才基本消失。
另外一个容易忽略的点:工具描述里除了说“这个工具是干什么的”,最好还写清楚“什么情况下该用”“调用前需要用户提供什么”。比如“在用户询问订单进度或物流状态时使用;调用前先向用户确认订单号”。这看起来像在教模型,实际上是在降低乱调用概率。实测下来,加了这类描述之后,工具误调用的次数减少了一半以上。
3. 核心实现与端到端验证:从最小骨架到全链路跑通
设计讲完,进入实现。这一章我尽量保留可以直接抄的代码和清单。
3.1 MCP Server 最小实现骨架
我们用的 FastMCP 实现,代码简化后长这样:
from mcp.server.fastmcp import FastMCP import json import os import requests mcp = FastMCP(name="internal-order-mcp") API_TOKEN = os.environ.get("INTERNAL_API_TOKEN", "") @mcp.tool() def query_order_status(order_id: str, include_detail: bool = False) -> str: """在用户询问订单状态或物流进度时使用,调用前先向用户确认订单号。 参数: order_id: 订单号,例如 ORD-2025-001。 include_detail: 是否返回包含物流信息的完整详情,默认 False。 返回: 订单状态的 JSON 字符串,包含当前状态、更新时间、物流信息。 """ if not order_id.startswith("ORD-"): return json.dumps( {"error": "订单号格式不正确,需要以 ORD- 开头"}, ensure_ascii=False ) try: resp = requests.get( f"http://internal-order-svc/v1/orders/{order_id}", params={"detail": include_detail}, headers={"Authorization": f"Bearer {API_TOKEN}"}, timeout=5, ) except requests.exceptions.Timeout: return json.dumps({"error": "订单服务响应超时,请稍后重试"}, ensure_ascii=False) if resp.status_code == 200: return resp.text return json.dumps( {"error": f"订单服务返回异常状态码 {resp.status_code}"}, ensure_ascii=False ) if __name__ == "__main__": mcp.run(transport="streamable-http")代码逻辑很简单,但有几个细节必须提:
- API_TOKEN 从环境变量读取,不写死在代码里。MCP Server 接内部服务时,身份信息必须在 Server 层统一收敛,否则每个工具函数里都要重复传 token,维护起来很痛苦。
- 输入校验放在工具函数最前面。JSON Schema 能拦截一部分问题,但内部服务依然可能收到脏参数,所以 Server 层必须再做一次轻量校验。
- 异常要吞掉并返回人类可读信息。MCP 工具返回给 LLM 的内容会直接进入模型上下文。如果这里把原始堆栈抛回去,模型会一本正经地胡说八道,编造一个不存在的订单状态。这个点我们是在错误注入测试时才真正意识到的。
启动方式很直接:python server.py,服务默认起在本地 8000 端口。写完这段代码后,第一件事不是接 Claude Desktop,而是先用 MCP Inspector 把所有工具手动调一遍,这个习惯帮我省了大量调试时间。
3.2 客户端接入:Claude Desktop、自研 Agent 和 Cursor
Claude Desktop 这类客户端,配置写在claude_desktop_config.json里,Windows 一般在%APPDATA%\Claude\claude_desktop_config.json,macOS 在~/Library/Application Support/Claude/claude_desktop_config.json。写法如下:
{ "mcpServers": { "internal-order-mcp": { "url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http" } } }这里有一个容易忽略的坑:url 必须以/mcp结尾,这是 streamable-http 的路径约定。如果只写到http://127.0.0.1:8000,客户端会报 404 或者直接显示 disconnected。保存配置后重启 Claude Desktop,在 MCP 设置里看到服务状态变为 connected,才算接入成功。
自研 Agent 用官方 Python SDK 接入,代码大概是:
from mcp import ClientSession async def main(): async with ClientSession( "http://127.0.0.1:8000/mcp", transport="streamable-http" ) as session: tools = await session.list_tools() print([t.name for t in tools]) result = await session.call_tool( "query_order_status", {"order_id": "ORD-2025-001", "include_detail": True} ) print(result) asyncio.run(main())我只写这一点点客户端代码,是因为实际项目中客户端往往不是核心难点,MCP 生态里 Cursor、Codex、Claude 都原生支持,接入动作基本就是配置文件加几行 JSON。真正需要反复打磨的是 Server 端工具设计和返回质量。
3.3 端到端验证:不能只靠“能跑通”
MCP 接入的端到端验证,比普通接口测试麻烦不少。普通接口测试是确定性的——请求发过去,响应和预期比对,通过或失败一目了然。MCP 场景却多了一个大模型在中间,同一个输入每次输出的 token 可能都不同,工具调用路径也不完全一样。所以我把验证拆成三层:
- 连接层验证:MCP Server 能不能被客户端正常发现,工具列表是否完整。
- 工具层验证:每个工具的参数格式、返回格式、异常路径是否符合契约。
- 场景层验证:用自然语言让模型完成真实任务,观察多轮对话、意图识别、错误恢复。
下面是我当时贴在团队共享文档里的验证清单,每次发版前按这个跑一遍:
| 验证场景 | 方法 | 通过标准 |
|---|---|---|
| 单工具调用 | 用 MCP Inspector 直接调用 query_order_status | 返回正确,响应时间小于 5 秒 |
| 参数自动纠正 | 发一句“查一下订单 abc 的状态” | 模型识别出订单号异常,提醒用户更正 |
| 多轮上下文 | “查询订单 ORD-001,再查它的物流” | 第二轮不再重复询问订单号,直接复用上下文 |
| 错误注入 | 临时让内部订单服务返回 500 | 工具返回友好错误信息,模型不编造数据 |
| 并发调用 | 同时开启 20 个会话调用同一工具 | 无大面积 500,平均延迟没有明显劣化 |
| 鉴权失效 | 把 API_TOKEN 设置成过期值 | 返回明确鉴权错误提示,不抛原始堆栈 |
这套清单最大的价值,是把“模型调用工具”这个黑盒拆成了可控的检查点。尤其是错误注入那一栏:如果内部服务挂了,MCP Server 返回的是干净的错误信息,模型就会如实告诉用户“暂时查不到”;如果 Server 把异常堆栈丢给模型,模型就会编造一个看似合理的订单状态。这个现象在真实测试里出现过不止一次,所以我把“异常返回格式”和“参数输入校验”并列成了 Server 实现的硬性要求。
4. 踩坑记录与排查技巧
端到端验证过程中踩的坑,比前面所有设计阶段加起来都多。我按高频程度整理成速查表,再展开讲几个典型的排查思路。
4.1 高频问题速查表
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 客户端一直显示 disconnected | url 写错,或者用了不支持的传输方式 | 检查 /mcp 路径,查看 server 启动日志 |
| 工具列表为空 | server 端工具没注册成功,或 CORS 未配置 | 浏览器直接访问 /mcp 端点看返回内容 |
| 调用工具超时 | 内部服务响应慢,或 requests timeout 太短 | 先测内部接口时延,再调整 Server 超时参数 |
| 参数传递出错 | JSON Schema 没标 required,或 description 写得太模糊 | 用 Inspector 查看实际 schema,补全 required 和枚举说明 |
| 同一工具被反复调用 | prompt 没约束,模型上下文混乱 | 在工具描述里写清前置条件和使用时机 |
这里最核心的教训是:MCP Server 调试必须开日志。我们初期用 print 大法打点,后来发现根本不够用。真正好用的是给每个工具调用加 request_id,再把模型的会话 ID 和内部系统的 trace ID 串起来。跨栈系统里,没有统一的追踪标识,排查问题就是在三个团队、四套服务里大海捞针。
4.2 MCP Inspector 是排查利器,建议早用
调试 MCP Server,我最推荐的工具是官方 MCP Inspector,启动方式:
npx @modelcontextprotocol/inspector python server.pyInspector 会拉起一个本地 Web 页面,填好 Server 地址后,你在页面里能做的事非常多:
- 查看注册的所有工具定义和 JSON Schema 完整内容;
- 手动发起工具调用,看原始返回结果;
- 查看客户端和 Server 握手过程的原始请求和响应;
- 模拟不同客户端的连接行为,排查传输层兼容问题。
我们当时排查“工具列表为空”这个诡异问题时,靠的就是 Inspector。把 url 直接贴到浏览器里访问,发现返回的 JSON 中 tools 数组长度是 0,才意识到 Server 代码里装饰器注册逻辑出了问题。这个问题如果用 Claude Desktop 去查,至少要多花两个小时,因为客户端把很多底层细节都包装掉了。
建议:MCP Server 代码写完后,第一件事就是开 Inspector,把所有工具逐个手动调用一遍。很多人习惯先连客户端再调试,发现问题时已经分不清是哪一层的问题。
4.3 安全与版本兼容:跨栈场景最容易翻车
最后一块坑在安全策略和版本兼容上。
CORS 是被低估的一环。如果你把 MCP Server 部署在远程,客户端页面和 Server 不同源,没开 CORS 时工具调用会全部失败。开发环境可以先放开,生产环境用 allowed_origins 白名单收口。这个配置在 FastMCP 里加一行参数就行,但很容易被忽略。
反向代理也有讲究。Server 放内网时,客户端从外网访问,nginx 配置里要正确转发/mcp的 GET 和 POST 请求,而且不能对 SSE 流做 buffering。我们当时就是因为 nginx 没关缓冲,导致长连接响应迟迟不返回,排查了一个下午。
版本兼容更微妙。MCP SDK 迭代速度极快,Server 端用新版 SDK 实现 streamable-http,旧版客户端不一定认识。我们上线前两天才发现某个版本的目标客户端只支持 SSE,临时切传输方式才化险为夷。从那以后,维护一个“客户端版本 × 传输层”的兼容矩阵就成了惯例。
5. 复盘:做得对的和下次会改的
最后聊聊整体复盘。这些都是基于这次项目的个人经验判断,不是标准答案,但希望能在你启动类似项目时提供一些参考坐标。
5.1 这次做对了几件事
第一,需求澄清阶段没有急着写代码。业务方提 MCP 需求时,我们先把需求类型和接入边界定了,后面选型和开发都非常顺。如果一开始就动手,大概率会做出一个“能跑但没人愿意维护”的东西。
第二,工具描述和参数 schema 花了足够时间打磨。这一步是隐形成本,但直接决定了模型调用成功率。项目后期我们花了大量时间做工具返回内容优化,做了很多基于使用反馈的小调整,一步一步把调用成功率从最初的四成提升到了稳定的九成以上。
第三,端到端验证用了“连接层、工具层、场景层”三层清单,把模型调用这个黑盒拆成了可检查项。每次发版前按清单跑完,基本就能确认本轮改动没有破坏关键路径。
5.2 如果再来一次,我会提前做这几件事
重来一遍的话,至少在三个方面我会调整:
- 更早引入 MCP Inspector。我们是在客户端接入环节遇到问题后才开始用它,实际上 Server 写完就应该用它做一轮全量工具校验。
- 更早做并发和超时测试,而不是拖到上线前才压测。并发场景下暴露的问题往往和接口时延、超时设置相关,早测得早安心。
- 项目前端就维护客户端版本兼容矩阵。MCP 生态更新太快,等上线前两天发现某个客户端不支持我们选的传输方式,就只能临时换方案,非常被动。
最后分享一个小技巧。所有工具函数的返回,我都建议设计成“LLM 可直接理解、用户可直接阅读”的字符串,而不是把内部 JSON 原样丢给模型。比如订单查询的结果,不要只返回原始 JSON 字段,而是拼成“订单 ORD-2025-001 当前状态为处理中,预计 2 月 28 日送达”。这个习惯让我们的多轮对话效果显著提升,模型复述信息时几乎不会出错。MCP 接入的核心从来不是协议通没通,而是模型能不能在正确的时机用正确的工具,把准确的信息带回来。