ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

REST API 转 MCP 服务实战:工具粒度、Schema 设计与封装模板

REST API 转 MCP 服务实战:工具粒度、Schema 设计与封装模板 REST API 和 MCP 之间的差距不是协议转换那么简单。我最近把手上三个内部服务从传统 REST 接口改造成了 MCP 服务踩了一圈坑之后发现真正难的地方在于REST 是给人写代码调用设计的而 MCP 是给模型自主决策调用设计的。这两者的消费主体不同导致接口设计哲学完全不一样。如果你只是把 OpenAPI 文档丢给模型让它自己看着办大概率会得到一个能跑但不好用的结果——模型要么选错工具要么传错参数要么在多个相似接口之间反复横跳。这篇内容适合两类人一是手里已经有成熟 REST API、想快速接入 MCP 生态的后端工程师二是正在设计 MCP Server、需要理解工具描述怎么写模型才不犯迷糊的 AI 应用开发者。我会从实际改造过程出发把工具粒度怎么切、Schema 怎么设计、错误怎么返回、鉴权怎么处理这些关键决策点全部拆开讲最后给出一套可以直接套用的封装模板。1. 先搞清楚 REST 和 MCP 的本质差异在哪1.1 消费主体变了设计逻辑就得跟着变REST API 的消费者是程序员。程序员会读文档、理解业务语义、在代码里写死调用逻辑。所以 REST 接口可以很薄——一个/api/v1/users/{id}返回一堆字段调用方自己决定怎么用。接口不需要解释什么时候该调我因为程序员在写代码的时候就已经想清楚了。MCP 的消费者是语言模型。模型没有业务上下文它只能根据工具的 name、description 和 inputSchema 来判断用户这个需求该用哪个工具、参数怎么填。这意味着 MCP 工具必须自解释——描述里要写清楚使用场景、参数含义、返回值结构甚至要写明什么情况下不该用我。我举个实际例子。原来我们有一个订单查询接口GET /api/orders?statuspendinguser_id123REST 文档里就一句话查询订单列表支持按状态和用户筛选。 改成 MCP 工具之后description 变成了这样查询指定用户的订单列表。当用户询问我的订单订单状态待处理订单时使用此工具。 status 参数可选值pending待处理、shipped已发货、completed已完成、cancelled已取消。 如果不确定状态不要传 status 参数返回全部订单。 注意此工具只能查询当前已认证用户的订单不要尝试查询其他用户的订单。差别在哪REST 文档是给人看的MCP 描述是给模型做决策用的。模型需要在几百个工具里选出正确的那一个描述写得含糊它就会犯错。1.2 工具粒度一个 REST 接口不等于一个 MCP 工具这是最容易踩的坑。很多人做封装的时候习惯性地把每个 REST endpoint 映射成一个 MCP tool。结果就是工具数量爆炸模型选择困难。我一开始也是这么干的。一个用户服务有 12 个 endpoint我就注册了 12 个工具。测试的时候发现模型经常在get_user_by_id、get_user_by_email、search_users这三个工具之间反复横跳因为它分不清什么时候该用哪个。后来我调整了策略按业务意图合并而不是按 HTTP 方法映射。把根据 ID 查用户和根据邮箱查用户合并成一个find_user工具参数设计成identifier可以是 ID 或邮箱description 里说明传入用户 ID 或邮箱地址均可。工具数量从 12 个降到 5 个模型的选择准确率明显提升。这里有个经验法则如果一个工具的描述需要超过三句话才能说清楚什么时候用它那它可能应该被拆分如果两个工具的描述有超过 50% 的重叠那它们可能应该被合并。1.3 返回值设计模型不需要那么多字段REST 接口通常返回完整的资源对象因为前端可能要用到所有字段。但模型不需要。模型需要的是足够做下一步决策的信息。比如订单查询接口REST 返回 30 个字段创建时间、更新时间、支付方式、物流单号、商品明细、优惠券信息……。但模型在对话场景下用户问我的订单到哪了它只需要知道订单号、状态、物流状态、预计送达时间。返回 30 个字段不仅浪费 token还会干扰模型的判断。我的做法是在 MCP 工具层做一次投影只返回模型决策必需的字段其余字段如果需要通过单独的获取详情工具按需拉取。这样既控制了 token 消耗也让模型的输入更干净。2. 工具描述和 Schema 设计让模型不犯迷糊的关键2.1 description 的写法有讲究工具描述不是文档是给模型的提示词。我总结了一个三段式模板第一段写功能定义这个工具做什么一句话说清楚。第二段写使用场景什么情况下该用这个工具列举 2-3 个典型用户表达。第三段写边界和禁忌什么情况下不该用有什么注意事项。举个例子一个发送邮件的 MCP 工具描述向指定收件人发送邮件。当用户说发邮件给某人帮我通知某人发送一封关于XX的邮件时使用此工具。 recipient 必须是有效的邮箱地址如果用户只提供了姓名没有提供邮箱先使用 find_contact 工具查询邮箱。 subject 和 body 为必填项如果用户没有明确说明邮件内容先向用户确认再调用。 注意此工具会真实发送邮件调用前必须确认收件人和内容无误。最后那句调用前必须确认很重要。模型有时候会自作主张加上这种约束能有效降低误操作。2.2 inputSchema 的设计原则MCP 使用 JSON Schema 来描述工具参数。我踩过的坑主要集中在几个地方必填项和可选项要分清。很多人把所有参数都标成 required结果模型每次调用都要编造参数值。实际上只有真正必需的才标 required可选的参数在 description 里说明不传时的默认行为。枚举值要列全。如果某个参数只接受特定值一定要用enum列出来并在 description 里解释每个值的含义。模型看到 enum 会直接选不会自己编。参数命名要语义化。id不如user_id清晰q不如search_keyword清晰。模型对参数名的理解直接影响填参准确率。嵌套结构要谨慎。如果 REST 接口接受复杂的嵌套 JSONMCP 工具层最好把它拍平。模型处理深层嵌套结构的能力有限拍平之后准确率会高很多。下面是一个实际的 Schema 示例{ name: search_orders, description: 搜索当前用户的订单。当用户询问订单相关问题且未指定具体订单号时使用。, inputSchema: { type: object, properties: { status: { type: string, enum: [pending, shipped, completed, cancelled], description: 订单状态筛选。不传则返回所有状态的订单。 }, keyword: { type: string, description: 搜索关键词匹配商品名称。不传则不按关键词筛选。 }, limit: { type: integer, description: 返回结果数量上限默认 10最大 50。, default: 10 } }, required: [] } }注意required是空数组——所有参数都是可选的。这样模型在信息不足时也能调用不会因为缺参数而卡住。2.3 工具命名动词开头语义明确工具名是模型选择的第一依据。我的命名规范是动词_名词全小写下划线分隔。比如search_orders、create_ticket、update_profile、send_notification。避免的命名方式orderSearch驼峰模型有时候会搞混大小写、getOrderList动词不明确get太泛、order_query_v2带版本号模型不理解版本含义。如果两个工具功能相近在名字上就要体现差异。比如search_orders搜索和get_order_detail获取详情名字本身就说明了使用场景的不同。3. 从 REST 到 MCP 的封装实操以订单服务为例3.1 整体架构适配层放在哪我的做法是在 REST API 和 MCP Server 之间加一个适配层而不是直接把 MCP Server 怼到 REST 接口上。适配层负责四件事参数转换MCP 的扁平参数转成 REST 的请求体、返回值投影裁剪字段、错误映射HTTP 错误码转成模型能理解的错误信息、鉴权透传把 MCP 的认证信息转成 REST 的 Token。这样做的好处是REST API 不需要任何改动MCP Server 也不依赖具体的 REST 实现细节。如果以后 REST API 升级了只需要改适配层。架构大概是这样的模型 → MCP Client → MCP Server → 适配层 → REST API → 数据库适配层可以用任何语言写我用的是 Python FastAPI因为 MCP 的 Python SDK 比较成熟而且 FastAPI 的 Pydantic 模型和 JSON Schema 天然契合。3.2 工具注册从 OpenAPI 自动生成还是手写我试过两种方式。自动生成解析 OpenAPI spec 转成 MCP tool的问题是生成的 description 是 REST 文档的原文对模型不友好参数名和结构也是 REST 风格的没有做扁平化处理。结果就是能跑但不好用。手写的问题是工作量大而且 REST API 变更后需要同步维护。我最后的方案是半自动用脚本从 OpenAPI spec 生成工具骨架name、inputSchema 的基本结构然后手动补充 description、调整参数命名、设置默认值。这样既保证了效率又保证了质量。具体流程解析 OpenAPI spec提取每个 endpoint 的 path、method、parameters、requestBody按业务意图合并相关 endpoint比如把 GET /users/{id} 和 GET /users?email 合并成 find_user生成 MCP tool 的 JSON Schema 骨架人工审核并补充 description、调整参数、设置 required 和 default注册到 MCP Server第 2 步的合并逻辑需要人工判断这是自动化做不了的。我的经验是先让脚本生成所有 endpoint 的列表然后人工标注哪些应该合并、哪些应该拆分、哪些应该隐藏比如内部管理接口不需要暴露给模型。3.3 参数转换扁平到嵌套的映射MCP 工具的参数是扁平的但 REST API 可能接受嵌套的请求体。适配层需要做转换。比如创建订单的 REST 接口POST /api/orders { user_id: 123, items: [ {product_id: p1, quantity: 2}, {product_id: p2, quantity: 1} ], shipping_address: { province: 浙江省, city: 杭州市, detail: XX路XX号 } }MCP 工具的参数设计成{ user_id: string, product_ids: array of strings, quantities: array of integers, shipping_province: string, shipping_city: string, shipping_detail: string }适配层负责把product_ids和quantities按索引配对组装成items数组把shipping_*三个参数组装成shipping_address对象。这里有个细节数组参数的配对关系要在 description 里写清楚。我会加一句product_ids 和 quantities 按位置一一对应长度必须相同。模型看到这句话就知道怎么填了。3.4 错误处理让模型能看懂错误信息REST API 的错误返回通常是 HTTP 状态码 错误码 错误消息。模型对 HTTP 状态码没有直觉它需要的是自然语言的错误描述和修复建议。我的错误映射策略REST 错误MCP 错误返回400 Bad Request参数错误{具体字段} 格式不正确请检查后重试401 Unauthorized认证失败当前会话未授权请重新登录403 Forbidden权限不足当前用户没有执行此操作的权限404 Not Found资源不存在未找到指定的{资源类型}请确认 ID 是否正确429 Too Many Requests请求过于频繁请稍后重试建议等待 {retry_after} 秒500 Internal Error服务暂时不可用请稍后重试如果持续出现请联系管理员关键点是错误信息要包含下一步该怎么做。模型看到参数错误可能不知道怎么办但看到参数错误user_id 格式不正确请检查后重试就知道要重新获取 user_id。另外MCP 工具的错误返回应该用isError: true标记而不是抛异常。这样模型能区分工具执行成功但业务失败和工具本身出错了。4. 鉴权、限流和可观测性工业级封装绕不开的三件事4.1 鉴权MCP 的认证信息怎么传到 RESTMCP 协议本身支持在初始化时传递认证信息。我的做法是在 MCP Server 启动时配置一个服务账号的 Token所有通过 MCP 发起的请求都用这个 Token 去调 REST API。然后在适配层记录哪个 MCP 会话发起的请求用于审计。如果需要对不同用户做区分可以在 MCP 工具的参数里加一个user_context字段由 MCP Client 在调用时自动填充。但这个方案需要 Client 端配合实际落地时要看具体的 MCP 宿主环境支持到什么程度。注意不要把 REST API 的 Token 直接暴露在 MCP 工具的 description 或 Schema 里。Token 应该通过环境变量或配置文件注入不能出现在模型可见的任何地方。4.2 限流防止模型疯狂调用模型有时候会陷入循环反复调用同一个工具。如果没有限流可能会把后端打挂。我在适配层做了两级限流单会话限流每个 MCP 会话每分钟最多 30 次工具调用和全局限流整个 MCP Server 每分钟最多 200 次调用。超过限制时返回一个友好的错误信息操作过于频繁请等待几秒后重试。另外对于写操作创建、更新、删除我额外加了一个幂等键机制。同一个会话在 60 秒内用相同参数调用同一个写工具直接返回上一次的结果不重复执行。这能有效防止模型因为超时重试导致的重复创建。4.3 可观测性出问题了怎么排查MCP 服务的排查比 REST 麻烦因为调用链多了一层模型 → MCP Client → MCP Server → 适配层 → REST API。我的做法是在每一层都打上 trace_id从 MCP 工具调用开始生成一路透传到 REST API 的请求头里。日志里记录的关键信息会话 ID、工具名、输入参数脱敏后、REST 请求的 URL 和状态码、返回值摘要、耗时。这样出问题的时候拿 trace_id 一搜就能看到完整链路。我还加了一个工具调用统计的看板按工具名统计调用次数、成功率、平均耗时。这个数据对优化工具描述很有帮助——如果某个工具的调用失败率特别高说明要么描述不清楚要么参数设计有问题。5. 实测中遇到的几个典型问题和处理方式5.1 模型选错工具description 背锅测试的时候发现用户说帮我查一下最近的订单模型有时候会调get_order_detail需要订单号而不是search_orders。原因是get_order_detail的 description 里写了查询订单信息和search_orders的搜索订单语义太接近。修复方式是在get_order_detail的 description 里加了一句此工具需要提供具体的订单号如果用户没有提供订单号请使用 search_orders 工具。加上这句之后选错的情况基本消失了。这给我的启发是工具描述不仅要说明我是什么还要说明我和别人的区别在哪。特别是功能相近的工具一定要在描述里做交叉引用。5.2 参数填错枚举值没列全有个工具的status参数REST API 接受active、inactive、suspended三个值但我在 Schema 里只列了前两个。结果模型遇到需要查suspended状态的场景时自己编了一个disabled传进去REST API 返回 400。这个问题的根因是 Schema 和实际 API 不一致。修复方式很简单把 enum 补全就行。但教训是Schema 里的 enum 必须和 REST API 的实际取值完全一致不能有遗漏。我后来写了一个校验脚本定期对比 OpenAPI spec 和 MCP Schema 的 enum 值发现不一致就报警。5.3 返回值太大token 爆炸有个获取用户详情的工具REST API 返回的用户对象包含头像的 base64 编码大概 200KB。模型调用一次token 直接爆了。修复方式是在适配层做字段过滤把avatar_base64这类大字段直接剔除只返回avatar_url。如果模型确实需要头像让它用单独的获取头像工具按需拉取。这个问题的通用解法是在适配层维护一个字段白名单只返回白名单里的字段。白名单根据模型的实际需求来定宁少勿多。5.4 超时处理模型等不及有些 REST 接口的响应时间比较长比如报表生成要 10-30 秒。MCP 工具调用默认的超时时间可能不够导致模型收到超时错误后重试反而加重后端负担。我的处理方式是把这类慢操作改成异步模式工具调用立即返回一个task_id然后提供另一个check_task_status工具让模型轮询。这样既不会超时也不会重复触发。轮询的间隔建议在 description 里写清楚调用后等待 5 秒再查询状态不要连续频繁查询。 模型看到这个提示会遵守。6. 一套可以直接套用的封装模板6.1 目录结构mcp-server/ ├── config/ │ ├── settings.py # 环境变量、REST API 地址、Token │ └── tools.yaml # 工具定义name、description、schema ├── adapter/ │ ├── base.py # 适配层基类参数转换、错误映射、字段投影 │ ├── order_adapter.py # 订单服务的适配器 │ └── user_adapter.py # 用户服务的适配器 ├── tools/ │ ├── registry.py # 工具注册中心 │ └── handlers.py # 工具处理函数 ├── middleware/ │ ├── auth.py # 鉴权中间件 │ ├── rate_limit.py # 限流中间件 │ └── logging.py # 日志和 trace └── main.py # MCP Server 入口6.2 适配层基类的核心逻辑class BaseAdapter: def __init__(self, base_url: str, token: str): self.base_url base_url self.headers {Authorization: fBearer {token}} def transform_params(self, mcp_params: dict) - dict: MCP 扁平参数转 REST 请求参数子类覆写 return mcp_params def project_response(self, rest_response: dict) - dict: REST 返回值投影子类覆写 return rest_response def map_error(self, status_code: int, error_body: dict) - str: HTTP 错误转模型可读的错误信息 error_map { 400: f参数错误{error_body.get(message, 请检查输入)}, 401: 认证失败当前会话未授权, 403: 权限不足没有执行此操作的权限, 404: 资源不存在请确认 ID 是否正确, 429: 请求过于频繁请稍后重试, 500: 服务暂时不可用请稍后重试, } return error_map.get(status_code, f未知错误{status_code}) async def call_rest(self, method: str, path: str, **kwargs) - dict: 调用 REST API 并处理错误 async with httpx.AsyncClient() as client: resp await client.request( method, f{self.base_url}{path}, headersself.headers, **kwargs ) if resp.status_code 400: raise ToolError(self.map_error(resp.status_code, resp.json())) return self.project_response(resp.json())6.3 工具注册的配置化写法把工具定义写在 YAML 里代码只负责加载和注册tools: - name: search_orders description: | 搜索当前用户的订单。当用户询问订单相关问题且未指定具体订单号时使用。 status 可选值pending、shipped、completed、cancelled。 不传 status 则返回所有状态。 adapter: order method: search_orders schema: type: object properties: status: type: string enum: [pending, shipped, completed, cancelled] description: 订单状态筛选 keyword: type: string description: 搜索关键词匹配商品名称 limit: type: integer default: 10 description: 返回数量上限最大 50 required: []这样新增工具只需要改 YAML不用动代码。工具描述也可以让非技术人员参与优化降低了维护门槛。6.4 上线前的检查清单我在每次发布 MCP Server 之前都会过一遍这个清单所有工具的 description 是否包含使用场景和边界说明所有 enum 是否和 REST API 的实际取值一致所有必填参数是否真的必填可选项是否有默认值返回值是否做了字段投影有没有大字段泄漏错误映射是否覆盖了所有可能的 HTTP 状态码限流配置是否合理写操作是否有幂等保护trace_id 是否贯穿了 MCP Server 和 REST API有没有工具的描述之间存在语义重叠容易导致模型选错这个清单看起来简单但每次都能查出几个问题。特别是第 8 条工具多了之后很容易出现描述重叠需要定期审查。7. 关于工具数量膨胀的一点个人经验最后聊一个我踩过的坑。一开始我觉得工具越多越好恨不得把每个 REST endpoint 都暴露出去。结果模型在 50 多个工具里选择准确率惨不忍睹。后来我做了减法把工具数量控制在 15 个以内每个工具对应一个明确的业务意图。具体做法是把 CRUD 操作合并成查询和变更两类工具通过参数区分具体操作把内部管理接口全部隐藏不暴露给模型把低频工具下线需要时再按需注册工具数量降下来之后模型的调用准确率从 60% 左右提升到了 90% 以上。这个经验让我意识到MCP 工具的设计目标不是覆盖所有功能而是让模型在常见场景下能选对、填对。覆盖度可以慢慢补但准确率是底线。另外我建议在 MCP Server 里加一个工具使用统计的埋点记录每个工具被调用的次数和成功率。上线一段时间后把调用次数为 0 的工具下线把失败率高的工具重新审查描述。这样持续迭代工具集会越来越精炼。
返回列表