1. 从 REST 到 MCP:为什么现有接口需要一次“协议升级”
手里有一套跑得好好的 REST API,突然要把它接进大模型工具链,很多人的第一反应是“再写个函数调用不就行了”。真动手才发现,函数调用只是把接口包了一层壳,模型看到的仍然是一堆散乱的参数描述,工具之间的调用边界、上下文传递、错误语义全靠人肉约定。MCP(Model Context Protocol)要解决的正是这个问题:它给模型和外部能力之间定义了一套标准化的“对话规则”,让工具不再是一个个孤立的函数,而是可被发现、可被组合、语义清晰的服务单元。
我最初接触这个需求,是因为团队内部有一批已经稳定运行了两三年的业务接口,覆盖订单查询、库存核对、报表导出这些高频操作。这些接口本身设计得不算差,RESTful 风格规整,鉴权、分页、错误码都有。但当我们尝试让模型去调用它们时,问题集中爆发:模型不知道某个接口到底该在什么场景下用,参数填错时拿到的是一段 HTTP 500 的原始报错,多步操作之间无法共享中间结果。换句话说,REST API 是给“知道自己在干什么的程序员”用的,而 MCP 服务是给“需要被引导着干活的模型”用的,两者的设计出发点根本不同。
所以这篇内容想聊的,不是“怎么把 REST 接口用代码包一层”这种表面功夫,而是如何把一套面向人类的 REST API,改造成面向模型的 MCP 服务。我会把整个封装过程拆成可复现的步骤,重点讲清楚每个设计决策背后的取舍:工具描述怎么写模型才不容易误解、参数校验放在哪一层、错误信息怎么翻译成模型能理解的语义、多个工具之间怎么共享上下文。适合已经有 REST 接口、想接入大模型工具链的开发者,也适合正在设计 MCP 服务、想少走弯路的同行。
需要提前说明的是,MCP 目前仍在快速演进,不同客户端对协议细节的支持程度有差异。下面提到的方案是我在实际项目中验证过的做法,但你在落地时需要结合自己使用的客户端版本做适配。核心思路是通用的,具体字段名和传输方式可能随版本变化。
2. 先想清楚:REST 和 MCP 到底差在哪一层
2.1 面向对象不同,设计语言就完全不同
REST API 的消费者是程序员。程序员看文档、理解业务、知道什么时候该调哪个接口。所以 REST 接口的设计可以很“薄”:一个/orders/{id}返回订单详情,参数就是路径里的 id,错误就返回标准 HTTP 状态码。这种设计对人类友好,因为人类有业务上下文,能自己补全缺失的信息。
MCP 服务的消费者是模型。模型没有业务上下文,它只能看到你给它的工具描述和参数 schema。如果描述写得含糊,模型就会在错误的场景调用错误的工具;如果参数没有约束,模型就会填出各种奇怪的组合。这意味着 MCP 服务的设计必须“厚”:每个工具都要有清晰的适用场景说明,参数要有明确的类型和取值范围,返回值要结构化到模型能直接理解的程度。
我踩过的一个典型坑:早期我把一个“查询订单”的 REST 接口直接映射成 MCP 工具,描述只写了“查询订单信息”。结果模型在用户问“我上周买的那个东西到哪了”的时候,根本不知道该调这个工具,因为它不知道“上周买的”对应的是订单查询,也不知道需要先拿到订单号。后来我把描述改成“根据订单号查询订单的物流状态和预计送达时间,适用于用户询问某个已知订单号的配送进度”,命中率立刻上来了。
2.2 错误处理:从状态码到可理解的语义
REST 的错误处理依赖 HTTP 状态码和错误体。404 表示资源不存在,400 表示参数错误,500 表示服务端异常。程序员看到这些码就知道该怎么处理。但模型看到 404 只会一脸茫然,它不知道是订单号填错了,还是这个订单真的不存在,还是接口路径写错了。
MCP 服务里,错误信息必须翻译成自然语言,并且要包含足够的上下文让模型能自我纠正。比如订单不存在,不能只返回“404”,而要返回“未找到订单号为 XXX 的订单,请确认订单号是否正确,或该订单可能已被取消”。这样模型才能判断下一步是该让用户重新提供订单号,还是告知用户订单已取消。
我在实际项目里做了一个错误映射层,把 REST 的错误码和错误信息转换成 MCP 的语义化错误。这个映射层不是简单的字符串替换,而是要根据业务语义做分类:参数类错误、权限类错误、资源类错误、系统类错误。不同类别的错误,模型需要采取的行动完全不同。
2.3 上下文传递:REST 无状态,MCP 需要“记忆”
REST 是无状态的,每次请求都是独立的。但模型和用户的对话是有状态的,多轮对话里模型需要记住之前拿到的信息。比如用户先问“我的订单到哪了”,模型调工具拿到订单号,然后用户问“那帮我改一下收货地址”,模型需要知道刚才那个订单号是什么。
MCP 服务本身不负责维护对话状态,这个责任在客户端。但 MCP 工具的设计要考虑到上下文传递的便利性。我的做法是,在工具的返回值里显式包含后续操作可能需要的标识符,并且在工具描述里说明这些标识符的用途。比如查询订单的工具返回订单号、订单状态、可执行的操作列表,这样模型在后续轮次里就知道可以用哪个订单号去调改地址的工具。
这里有个细节值得注意:不要在工具返回值里塞太多无关信息。模型处理上下文的能力有限,返回值越精简、越结构化,模型越容易抓住重点。我一般只返回后续操作必需的字段,其他信息通过单独的查询工具按需获取。
3. 工具描述:模型能不能用对,全看这一段
3.1 描述的三段式结构:场景、动作、边界
工具描述是 MCP 服务里最容易被低估的部分。很多人写描述就像写函数注释,一句话带过。但模型判断该不该调这个工具,全靠这段描述。我总结了一个三段式结构,实测下来效果比较稳。
第一段写适用场景:什么情况下该用这个工具。要具体到用户可能说的原话类型。比如“当用户询问某个订单的配送进度、预计送达时间、当前物流状态时使用此工具”。
第二段写动作和参数:这个工具具体做什么,需要什么输入。参数说明要包含类型、是否必填、取值范围、示例值。比如“order_id:字符串,必填,订单号,格式为 ORD 开头的 12 位字符,例如 ORD20240101001”。
第三段写边界和限制:什么情况下不该用这个工具,或者用了会有什么限制。比如“此工具仅支持查询最近 90 天内的订单,超过 90 天的订单请使用历史订单查询工具”。
这三段看起来啰嗦,但每段都在解决一个具体问题。场景段解决“该不该调”,动作段解决“怎么调”,边界段解决“调错了怎么办”。我对比过只写一句话描述和写三段式描述的效果,后者在模型正确调用率上能高出三成以上。
3.2 参数 schema:别让模型猜你的意图
MCP 工具的参数定义用的是 JSON Schema。这个 schema 不只是给程序看的校验规则,更是给模型看的填写指南。很多人把 schema 写得很简略,只标个类型,结果模型填参数时全靠猜。
我的经验是,schema 里每个字段都要有description,而且 description 要写得像给新人的说明。比如一个日期参数,不能只写"type": "string",要写"type": "string", "description": "订单创建日期,格式为 YYYY-MM-DD,例如 2024-01-15。不支持相对日期如'昨天',需要先转换为具体日期"。
枚举类型的参数要把所有可能值列出来,并且说明每个值的含义。比如订单状态参数,不能只写"enum": ["pending", "shipped", "delivered"],要在 description 里说明 pending 表示待发货、shipped 表示已发货、delivered 表示已送达。模型看到这些说明,才能正确地把用户说的“还没发货”映射到 pending。
还有一个容易被忽略的点:参数的默认值。如果某个参数有合理的默认值,一定要在 schema 里标出来。这样模型在不确定的时候可以省略这个参数,而不是瞎填一个值。比如分页参数page_size默认 20,模型就知道不填也行,不用自己编一个数字。
3.3 返回值设计:让模型一眼看懂结果
REST 接口的返回值往往是给前端渲染用的,字段多、嵌套深。直接把这个返回值丢给模型,模型很难抓住重点。MCP 工具的返回值需要重新设计,目标是让模型能快速判断“操作成功了没有”“拿到了什么关键信息”“下一步能做什么”。
我的做法是把返回值分成三个部分:状态、数据、后续操作提示。状态用简单的布尔值或枚举表示成功失败;数据只保留模型后续可能用到的字段,并且用扁平结构而不是深层嵌套;后续操作提示列出基于当前结果可以执行的其他工具,帮助模型规划下一步。
举个例子,查询订单的返回值可以设计成:{"success": true, "order_id": "ORD20240101001", "status": "shipped", "estimated_delivery": "2024-01-20", "available_actions": ["modify_address", "cancel_order"]}。这样模型一眼就知道订单已发货、预计 20 号送达、可以改地址或取消。如果返回值是一大坨嵌套 JSON,模型可能要找半天才能找到关键字段。
注意:返回值里的字段名要用模型容易理解的英文单词,不要用业务系统里的缩写。比如用
estimated_delivery而不是est_dlv,用order_status而不是ord_st。模型对缩写的理解能力远不如完整单词。
4. 封装实操:从零搭一个 MCP 服务层
4.1 技术选型:为什么我选了 TypeScript 而不是 Python
MCP 的官方 SDK 有 TypeScript 和 Python 两个版本。两个我都试过,最后在项目里选了 TypeScript。原因有几个:一是我们的 REST API 本身就是 Node.js 写的,用 TypeScript 可以复用现有的类型定义和工具函数;二是 MCP 的传输层用 stdio 和 SSE 比较多,Node.js 在这块生态更成熟;三是 TypeScript 的类型系统在定义工具 schema 时能提供更好的编译期检查。
当然 Python 版本也有优势,如果你的 REST API 是 Python 写的,或者团队更熟悉 Python,用 Python SDK 完全没问题。核心逻辑是一样的,只是语法差异。下面我用 TypeScript 举例,Python 用户可以对照着看,思路是通用的。
环境准备很简单,初始化一个 Node.js 项目,安装 MCP SDK:
npm init -y npm install @modelcontextprotocol/sdk npm install -D typescript @types/node npx tsc --inittsconfig.json里把target设成ES2022,module设成Node16,moduleResolution设成Node16。这几个配置和 MCP SDK 的模块解析方式匹配,不设的话可能会遇到导入报错。
4.2 工具注册:一个工具一个文件,别堆在一起
MCP 服务的核心是工具注册。每个工具需要定义名称、描述、参数 schema 和处理函数。我建议一个工具一个文件,放在tools/目录下,每个文件导出一个工具定义对象。这样做的好处是工具之间解耦,增删改工具不影响其他工具,也方便单独测试。
工具定义的基本结构是这样的:
import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const queryOrderTool: Tool = { name: "query_order", description: "根据订单号查询订单的物流状态和预计送达时间。适用于用户询问某个已知订单号的配送进度。仅支持查询最近90天内的订单。", inputSchema: { type: "object", properties: { order_id: { type: "string", description: "订单号,格式为ORD开头的12位字符,例如ORD20240101001" } }, required: ["order_id"] } };处理函数单独写,接收参数后调用 REST API,然后把结果转换成 MCP 的返回格式。这里的关键是错误处理:REST API 抛出的异常要捕获,转换成模型能理解的错误信息。
export async function handleQueryOrder(args: { order_id: string }) { try { const response = await fetch(`${API_BASE}/orders/${args.order_id}`, { headers: { Authorization: `Bearer ${API_TOKEN}` } }); if (!response.ok) { if (response.status === 404) { return { content: [{ type: "text", text: `未找到订单号为 ${args.order_id} 的订单。请确认订单号是否正确,或该订单可能已被取消。` }], isError: true }; } throw new Error(`API returned ${response.status}`); } const data = await response.json(); return { content: [{ type: "text", text: JSON.stringify({ success: true, order_id: data.id, status: data.status, estimated_delivery: data.estimated_delivery, available_actions: data.status === "shipped" ? ["modify_address"] : ["cancel_order"] }) }] }; } catch (error) { return { content: [{ type: "text", text: `查询订单时发生系统错误,请稍后重试。错误详情:${error.message}` }], isError: true }; } }注意isError这个字段。MCP 协议里,工具执行失败时要把isError设为true,这样客户端才知道这次调用没成功。但错误信息本身要写在content里,用自然语言描述,而不是只给一个错误码。
4.3 服务入口:把工具串起来
所有工具定义好后,在服务入口里注册。MCP 服务需要声明自己支持的能力,然后处理客户端的请求。
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { queryOrderTool, handleQueryOrder } from "./tools/queryOrder.js"; const server = new Server( { name: "order-service", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler("tools/list", async () => ({ tools: [queryOrderTool] })); server.setRequestHandler("tools/call", async (request) => { const { name, arguments: args } = request.params; switch (name) { case "query_order": return await handleQueryOrder(args as { order_id: string }); default: throw new Error(`Unknown tool: ${name}`); } }); const transport = new StdioServerTransport(); await server.connect(transport);这段代码里,tools/list返回所有可用工具的列表,tools/call根据工具名分发到对应的处理函数。StdioServerTransport表示通过标准输入输出通信,这是 MCP 最常用的传输方式,适合本地工具集成。
如果你需要远程调用,可以用 SSE 传输。SSE 的配置稍微复杂一点,需要起一个 HTTP 服务,把 MCP 的请求路由到对应的 handler。具体做法可以参考 SDK 文档,核心逻辑和 stdio 是一样的。
4.4 鉴权:别把 token 硬编码在代码里
REST API 通常需要鉴权,MCP 服务作为中间层,需要把鉴权信息透传下去。最直接的做法是在 MCP 服务里配置 API token,调用 REST API 时带上。但 token 不能硬编码在代码里,要用环境变量注入。
const API_TOKEN = process.env.API_TOKEN; if (!API_TOKEN) { throw new Error("API_TOKEN environment variable is required"); }如果每个用户的 token 不同,那就需要在 MCP 的请求里携带用户身份。MCP 协议本身没有定义鉴权机制,但可以在工具的输入参数里加一个user_token字段,或者通过传输层的 header 传递。具体用哪种方式,取决于你的客户端支持什么。
我一般倾向于在工具参数里加user_token,因为这样最灵活,不依赖传输层的特性。但缺点是每个工具都要处理这个参数,而且模型可能会在不需要的时候也填这个参数。折中方案是把user_token放在 MCP 服务的配置里,服务启动时注入,适用于单用户场景。
5. 联调阶段最容易翻车的几个地方
5.1 工具描述太长,模型反而抓不住重点
前面说描述要写详细,但详细不等于越长越好。我试过把一个工具的描述写到 500 多字,结果模型调用时经常忽略关键约束。后来发现,描述的长度要控制在一个合理范围内,核心信息放前面,次要信息放后面。
我的经验值是:场景描述控制在 50 字以内,参数说明每个字段不超过 30 字,边界说明不超过 50 字。整个描述控制在 200 字左右。如果确实有很多约束要说明,把次要约束放到参数的 description 里,而不是堆在工具描述里。
另外,描述里不要用太复杂的句式。模型对长句的理解能力有限,短句、简单句的效果更好。比如“此工具用于查询订单状态,仅支持最近 90 天内的订单,需要提供订单号”就比“此工具的主要功能是帮助用户查询其订单的当前状态信息,但需要注意的是该功能仅对最近 90 天内创建的订单有效,并且调用时必须提供有效的订单号作为输入参数”要好得多。
5.2 参数校验:在 MCP 层做还是 REST 层做
REST API 本身有参数校验,但那个校验是给程序员看的,错误信息往往是“invalid parameter”这种。模型拿到这种错误信息,不知道具体哪里错了。所以 MCP 层需要做一层前置校验,把错误信息翻译成模型能理解的。
我的做法是在 MCP 工具的处理函数里,先做一轮轻量校验:检查必填参数是否存在、格式是否明显不对。如果校验失败,直接返回语义化的错误信息,不调用 REST API。这样可以减少无效的 API 调用,也能给模型更快的反馈。
但要注意,MCP 层的校验不能替代 REST 层的校验。REST 层的校验是最后一道防线,MCP 层的校验只是提前拦截明显错误。两层校验的规则要保持一致,否则会出现 MCP 层放过了但 REST 层拒绝的情况,模型会困惑。
5.3 超时和重试:模型等不了太久
REST API 的响应时间通常在几百毫秒到几秒之间,但模型调用工具时,客户端往往有超时限制。如果工具执行时间太长,客户端会直接断开,模型拿不到任何结果。
我在项目里给每个工具设置了超时时间,默认 10 秒。超过 10 秒的 REST 调用,要么改成异步任务模式,要么在 MCP 层做超时控制,返回“操作正在处理中,请稍后查询”的提示。
重试也要谨慎。REST API 的 GET 请求可以安全重试,但 POST 请求重试可能导致重复操作。MCP 层不要自动重试写操作,而是把错误返回给模型,让模型决定是否重试。如果确实需要重试,要在 REST API 层做幂等设计,比如用请求 ID 去重。
5.4 日志:出问题时能快速定位
MCP 服务的日志很重要,因为模型调用工具的过程对开发者来说是黑盒。我在每个工具的处理函数里都加了日志,记录入参、出参、耗时、错误信息。日志格式用 JSON,方便后续分析。
console.error(JSON.stringify({ tool: "query_order", args: { order_id: args.order_id }, duration_ms: Date.now() - startTime, success: !isError }));注意日志要写到 stderr,不能写到 stdout。因为 stdio 传输模式下,stdout 是 MCP 协议的通信通道,写日志到 stdout 会干扰协议消息。这个坑我踩过,调试了半天才发现是日志输出到了错误的流。
6. 从能跑到好用:几个提升稳定性的细节
6.1 工具命名:用动词开头,保持一致性
工具名是模型选择工具的第一线索。命名要清晰、一致、可预测。我建议用“动词_名词”的格式,比如query_order、update_address、cancel_order。动词用常见的英文单词,不要用生僻词或缩写。
同一类操作的工具,命名风格要一致。比如查询类都用query_开头,更新类都用update_开头,删除类都用cancel_或delete_开头。这样模型在选择工具时,能通过命名快速缩小范围。
避免用get_order_info和fetch_order_detail这种同义不同词的命名,模型会困惑这两个工具有什么区别。如果确实有两个功能相似的工具,要在描述里明确说明它们的区别和适用场景。
6.2 返回值精简:只给模型需要的
前面提过返回值要精简,这里再展开说一下。REST API 的返回值往往包含很多字段,但模型后续操作可能只用得到其中几个。把整个返回值丢给模型,不仅浪费上下文窗口,还可能干扰模型的判断。
我的做法是定义一个“模型视图”,只保留模型需要的字段。这个视图和 REST API 的原始返回值解耦,REST API 改字段不影响 MCP 的返回值。转换逻辑写在 MCP 层,用显式的字段映射,而不是直接透传。
function toModelView(order: any) { return { order_id: order.id, status: order.status, estimated_delivery: order.estimated_delivery, available_actions: deriveActions(order) }; }deriveActions根据订单状态推导出可执行的操作列表。这个逻辑放在 MCP 层,而不是让模型自己去判断。模型看到available_actions里有modify_address,就知道可以改地址,不用自己去分析订单状态和业务规则。
6.3 多工具协作:让模型知道工具之间的关系
MCP 服务通常不止一个工具,多个工具之间可能有依赖关系。比如先查询订单拿到订单号,再根据订单号改地址。模型需要知道这个依赖关系,才能正确地编排工具调用。
我的做法是在工具描述里显式说明依赖关系。比如改地址的工具描述里写“需要先通过 query_order 工具获取订单号”。同时在 query_order 的返回值里包含available_actions,提示模型可以执行哪些后续操作。
还有一种做法是提供一个“工作流”工具,把多个步骤封装成一个工具。但这样会降低灵活性,模型无法根据中间结果调整后续步骤。我倾向于保持工具的原子性,通过描述和返回值来引导模型编排,而不是把逻辑写死在工具里。
6.4 版本管理:工具变更要平滑过渡
MCP 服务的工具定义变更时,要考虑客户端的兼容性。如果直接改工具名或参数 schema,正在使用旧版本的客户端可能会调用失败。我的做法是给工具加版本号,比如query_order_v2,旧版本保留一段时间,等客户端都升级后再下线。
参数 schema 的变更也要谨慎。新增可选参数是安全的,但删除参数或改参数类型是破坏性变更。如果必须做破坏性变更,就新开一个工具,而不是改现有工具。这样旧客户端不受影响,新客户端用新工具。
7. 实测下来,这套方案在哪些场景最划算
7.1 内部工具集成:收益最明显
MCP 封装最适合的场景是内部工具集成。团队内部已经有一批 REST API,现在想让模型能调用这些 API 完成自动化任务。这种情况下,REST API 的稳定性和业务逻辑都是现成的,MCP 层只需要做协议转换和语义适配,工作量可控,收益也很直接。
我做过一个内部报表系统的 MCP 封装,把报表查询、导出、订阅这几个 REST 接口包成 MCP 工具。封装完成后,模型可以通过自然语言查询报表、导出数据、设置订阅,不用再让用户去学报表系统的操作界面。整个封装过程大概花了三天,其中两天在调工具描述和错误处理,真正写代码的时间不多。
7.2 第三方 API 聚合:注意速率限制
如果 REST API 是第三方的,MCP 封装要多考虑一层:速率限制和配额。第三方 API 通常有调用频率限制,MCP 服务如果不做控制,模型可能会在短时间内发起大量调用,触发限流。
我的做法是在 MCP 层加一个简单的令牌桶限流器,控制每个工具的调用频率。同时把第三方 API 的配额信息缓存在 MCP 服务里,当配额快用完时,在工具返回值里提示模型“今日配额即将用完,请谨慎调用”。这样模型可以调整策略,而不是直接撞上限流。
7.3 高频写操作:谨慎封装
查询类的 REST API 封装成 MCP 工具比较安全,因为查询是幂等的,重复调用不会产生副作用。但写操作要谨慎,因为模型可能会误调用或重复调用。如果确实要封装写操作,建议加一层确认机制:工具执行前先返回一个“待确认”状态,让模型或用户确认后再真正执行。
我在项目里对写操作工具加了一个confirm参数,默认false。模型第一次调用时只做校验和预览,返回“将要执行的操作”让用户确认。用户确认后,模型再带confirm: true调用一次,才真正执行。这样虽然多一轮交互,但避免了误操作。
7.4 批量操作:拆成单个工具更稳
REST API 有时提供批量接口,比如批量查询订单、批量更新状态。直接把批量接口封装成 MCP 工具,模型需要构造一个数组参数,容易出错。我的做法是把批量操作拆成单个操作的工具,让模型循环调用。虽然调用次数多了,但每次调用的参数简单,模型不容易填错。
如果批量操作的性能确实是个问题,可以提供一个批量工具,但参数 schema 要设计得简单明了。比如批量查询订单,参数就是一个订单号数组,不要搞太复杂的嵌套结构。同时在描述里说明“一次最多查询 20 个订单,超过请分批调用”。
8. 几个我踩过的坑和对应的解法
8.1 工具描述里的“不要”反而被模型当成“要”
这是一个很反直觉的坑。我在工具描述里写“不要用于查询历史订单”,结果模型反而更倾向于用这个工具查历史订单。后来查了一些资料才明白,模型对否定句的处理能力比较弱,描述里的“不要”有时候会被忽略,甚至产生反向暗示。
解法是尽量用肯定句描述工具的适用范围,而不是用否定句排除。比如不写“不要用于查询历史订单”,而是写“仅适用于查询最近 90 天内的订单,历史订单请使用 query_history_order 工具”。把否定变成肯定,把排除变成引导,效果会好很多。
8.2 参数默认值导致模型“偷懒”
给参数设默认值本来是为了方便模型省略参数,但实际用下来发现,模型有时候会过度依赖默认值,即使应该填具体值的时候也省略。比如分页参数默认 20,模型在用户明确说“给我看 50 条”的时候,还是用了默认的 20。
解法是在参数描述里说明什么时候该覆盖默认值。比如“page_size:每页条数,默认 20。如果用户明确指定了数量,请使用用户指定的值”。这样模型就知道默认值不是必须用的,用户有要求时要覆盖。
8.3 错误信息太技术化,模型看不懂
REST API 返回的错误信息往往是技术化的,比如“ECONNREFUSED”“timeout”“invalid token”。这些信息对模型来说没有意义,模型不知道该怎么处理。我一开始直接把 REST 的错误信息透传给模型,结果模型要么忽略错误继续调用,要么给用户返回一段技术黑话。
解法是建一个错误映射表,把常见的技术错误映射成自然语言。比如ECONNREFUSED映射成“服务暂时不可用,请稍后重试”,timeout映射成“请求超时,可能是网络问题,请重试”,invalid token映射成“鉴权失败,请检查访问凭证”。映射表要覆盖常见的错误类型,并且定期更新。
8.4 工具太多,模型选择困难
当一个 MCP 服务有十几个甚至几十个工具时,模型的选择困难症就犯了。工具太多,描述再清晰,模型也可能选错。我试过一个服务里放了 30 多个工具,结果模型经常在相似的工具之间反复横跳。
解法是拆分服务。按业务域把工具分成多个 MCP 服务,每个服务只负责一个业务域,工具数量控制在 10 个以内。客户端可以同时连接多个 MCP 服务,模型在单个服务内的选择压力就小很多。另一个解法是提供“工具导航”工具,让模型先调用导航工具了解有哪些工具可用,再选择具体工具。但这样多一轮调用,适合工具特别多的场景。
8.5 返回值里的 JSON 被模型“脑补”
MCP 工具的返回值通常是 JSON 字符串。模型拿到 JSON 后,有时候会“脑补”一些不存在的字段,或者在后续对话里引用不存在的值。这个问题在返回值结构复杂的时候尤其明显。
解法是返回值尽量扁平,字段名用完整的英文单词,避免嵌套。同时在返回值里加一个_hint字段,用自然语言总结关键信息。比如"_hint": "订单 ORD20240101001 已发货,预计 2024-01-20 送达,可以修改地址"。这样模型即使没完全理解 JSON 结构,也能从 hint 里抓住重点。
9. 上线之后:监控和迭代怎么做
9.1 记录每次工具调用的完整链路
MCP 服务上线后,最重要的是可观测性。我在每个工具的处理函数里记录了完整的调用链路:请求 ID、工具名、入参、出参、耗时、错误信息、模型的选择理由(如果客户端提供了的话)。这些日志写到文件或日志服务,方便后续分析。
分析日志时重点关注几个指标:工具调用成功率、平均耗时、错误类型分布、模型选错工具的频率。如果某个工具的错误率明显偏高,可能是描述写得不够清楚,或者参数 schema 设计有问题。如果模型经常在几个工具之间犹豫,可能是工具之间的边界不清晰。
9.2 根据日志迭代工具描述
工具描述不是一次写好的,需要根据实际调用情况迭代。我一般每周看一次日志,把模型调用失败的 case 拿出来分析。如果是描述不清楚导致的,就改描述;如果是参数 schema 导致的,就改 schema;如果是工具设计本身的问题,就考虑拆分或合并工具。
改完描述后,不要一次性全量上线,先在小范围测试,对比改动前后的调用成功率。我试过改一个工具的描述,调用成功率从 70% 提升到 90% 多,但也有改完之后反而下降的情况。所以改动要有对比,不能凭感觉。
9.3 收集模型的“困惑信号”
模型在调用工具时,如果拿不准,可能会在返回给用户的消息里表达困惑,比如“我不确定该用哪个工具”。这些困惑信号是优化 MCP 服务的重要线索。我在客户端加了一个机制,把模型的困惑信号收集起来,定期分析。
困惑信号通常指向几个问题:工具描述有歧义、工具之间的边界不清晰、参数说明不完整。针对这些问题做优化,比盲目改描述有效得多。
9.4 版本迭代要留回滚余地
MCP 服务的每次变更都要留回滚余地。工具描述的改动、参数 schema 的调整、处理逻辑的优化,都可能引入意想不到的问题。我的做法是每次变更都打 tag,保留上一个版本的部署包。如果新版本上线后调用成功率下降,可以快速回滚。
回滚不只是代码回滚,工具描述和 schema 也要回滚。因为模型的行为受描述影响很大,描述改了但代码没改,或者代码改了但描述没改,都可能导致模型行为异常。所以变更要原子化,描述和代码一起改,一起回滚。
10. 关于这套方案,我个人的几点体会
封装 REST API 为 MCP 服务,技术上的难点其实不多,真正花时间的是“翻译”工作:把面向程序员的接口语义,翻译成面向模型的工具语义。这个翻译过程没有标准答案,需要根据实际调用情况不断调整。我做了几个 MCP 服务之后,最大的体会是:工具描述的重要性不亚于代码本身。代码写错了可以调试,描述写错了模型会一直用错,而且很难定位。
另一个体会是,不要试图一次性设计完美的工具集。先跑通最小可用版本,让模型用起来,然后根据实际调用日志迭代。我第一个 MCP 服务上线时只有三个工具,描述也写得很粗糙,但跑了一周之后,根据日志改了两轮描述,效果就明显上来了。迭代比一次性设计更重要。
最后分享一个小技巧:在工具描述里加一些“示例对话”,告诉模型在什么用户输入下该调用这个工具。比如“用户说‘我的快递到哪了’时使用此工具”。这个技巧对提升模型的调用准确率很有帮助,尤其是当工具的功能比较抽象、不容易用一句话说清楚的时候。示例对话不用多,一两个就够,但要覆盖典型的用户表达方式。