麦当劳 MCP 有 35 个工具,官方文档只写了 33 个:一次完整的 MCP 客户端接入实录

0 阅读7分钟

背景

麦当劳中国开放了一个 MCP Server(mcp.mcd.cn),提供门店查询、菜单、核价、下单、优惠券、营养数据等能力。 我在做一件很具体的事:把「这一单怎么点最省」变成一个可计算的问题。

但在写业务逻辑之前,先要和这个 MCP 打一架。这篇先讲打架的部分。


一、接口层:7 个实测坑

1. 工具数是 35,文档写的是 33

直接 tools/list:

// → 35 个
["now-time-info", "query-nearby-stores", "delivery-query-stores",
 "query-meals", "query-meal-detail", "calculate-price", "create-order",
 "query-my-coupons", "query-store-coupons", "query-promotions", ...]

多出来的两个是 query-promotions 和 query-survey-coupon,官方文档里没有列。

2. query-promotions 有隐藏适用范围

它的 description 里明确写着仅企业团餐 beType=6 可用。

这一条文档没提,但很关键 —— 如果你的方案依赖「满减券智能组合」,这条路是死的。 个人开发者只能拿到:麦麦省可领 / 我的券 / 门店券 / 一键领 / CSAT 奖券,共 5 类。

3. ⚠️ 一个静默陷阱:code vs productCode

这是我最想提醒的一条。

query-meals 返回的商品标识字段叫 code:

{ "code": "9900005456", "name": "巨无霸", "currentPrice": "25.5" }

但 calculate-price 要的是 productCode:

{ "storeCode": "1950564", "orderType": 1, "beType": 1,
  "items": [{ "productCode": "9900005456", "quantity": 1 }] }

传错的后果是:不报错,直接返回 price: 0。

没有 error 字段,没有 warning,HTTP 也是 200。你只会看到金额是 0,然后怀疑人生。 我一开始以为是商品不可售,查了很久。

建议:任何消费这个 MCP 的客户端,都在参数校验层加一条断言 —— 核价返回 price === 0 且 productList 非空时,判定为字段映射错误,而不是"该商品不可售"。

4. 返回不是纯 JSON

这一点决定了整个客户端架构。

所有工具的返回都是「API 说明 Markdown + 内嵌一行原始 JSON」的混合体。举个例子(简化):

# 查询附近门店

## 请求参数
...

## 返回示例
{"success":true,"code":0,"data":[{...}]}

所以必须写一个解析层:从 Markdown 里把那一行 {"success":...} 抠出来。

这里有个坑:不能用"取第一行看起来像 JSON 的" ,因为说明文档里也可能有 JSON 示例。 我的做法是用 JSONDecoder.raw_decode 从 {"success" 的位置开始尝试解析,让它自己找到边界:

function extractData(text) {
  const i = text.indexOf('{"success"');
  if (i < 0) return null;
  const [obj] = new JSONDecoder().raw_decode(text.slice(i));
  return obj.data;
}

5. 营养表是自己发明的格式,而且用「字面量 \n」分隔

list-nutrition-foods 返回的不是数组,是一坨:

[160]{productName,energyKcal,protein,fat,carbohydrate,sodium,calcium}:巨无霸,513,27,28,44,1010,190\n中杯可乐,150,0,0,38,15,5\n...

注意那个 \n —— 它在字符串里是字面的反斜杠加 n,不是真的换行。 所以 split('\n') 拿不到东西,得先 replace(/\n/g, '\n')。

第一次解析出来 0 条,我盯着看了十分钟才反应过来。

6. 价格是整数「分」

calculate-price 返回 4850 = ¥48.50。

这其实是好事 —— 全链路用整数分做算术,天生没有浮点误差。 我在整个项目里没有出现过一次 0.1 + 0.2 类的问题。

7. list-nutrition-foods 返回不稳定

首次调用正常,之后连续 5 次全部返回空。没有报错,就是空。

所以客户端必须有:重试 + 本地快照兜底。不能假设一次调用就能拿到数据。


二、架构取舍:为什么「数值不进 LLM」

这是我在这个项目里最坚持的一条。

一个看起来更省事的设计是:把菜单丢给 LLM,让它"智能推荐一个组合"。

我没这么做。 原因是:

问题后果
LLM 会算错价格用户按你的推荐下单,发现金额不对 —— 信任当场归零
LLM 不可复现同一个请求两次给不同答案,没法回归测试
LLM 无法证明最优你没法回答"你是不是真的找到了最便宜的?"
成本每次推荐都要烧 token

所以整个推荐链路是纯函数:

菜单 + 用户约束  →  枚举所有可行组合  →  多目标打分  →  Top-N 方案

大模型只在两个地方出现:一是把用户的自然语言「40 块以内别太胖」解析成结构化约束; 二是把结构化结果润色成人话。中间所有数字,一步都不经过 LLM。

好处是实打实的:

  • 同一个输入永远同一份输出 → 可以写断言测试(我这个项目有 27 条)
  • 价格由官方 calculate-price 交叉核验 → 不可能算错
  • 零 API 成本 → 可以让人随便玩

三、两个具体的技术问题

问题 A:套餐营养覆盖只有 59%

list-nutrition-foods 只覆盖单品。114 个在售 SKU 里,只有 67 个能直接匹配到热量。 所有套餐的热量官方都没给。

但有个口子:query-meal-detail 会返回套餐的默认组成。

安格斯厚牛堡四件套随心选
  ├─ 安格斯厚牛堡 ×1
  ├─ 中薯条 ×1
  ├─ 中杯可乐 ×1
  └─ 麦辣鸡翅 ×2

于是可以做:递归补全 —— 拿套餐的组成,去营养表里查每个组件,求和。

结果落盘缓存,第二次起零调用。补全后覆盖率从 59% 提到接近 100%。

但有个原则:仍然未知的,如实标注「热量待核实」,并且不允许用它去"满足"热量上限。

这一点很重要。早期版本里,未知热量被当成 0,结果一个「0 kcal 套餐」在所有"低卡"排序里都排第一。 这是用数据缺失伪造达标,比算错更危险。

问题 B:怎么保证"省钱"不是"少买"

这是个产品逻辑问题,但我觉得对做同类工具的人有参考价值。

我最早的版本按「同品类 + 更便宜」挑替代,结果它会把「安格斯四件套 ¥37」换成「巨无霸 ¥25.5」—— 看着省 ¥11.5,实际少了薯条和饮料。

那不是省钱,是少买。

修法是三条铁律:

  1. 只做同类替换:套餐只换套餐,单品只换单品
  1. 套餐内容不能变少:新套餐的组成必须覆盖原套餐(用 query-meal-detail 的真实组成比对)
  1. 补一条"打包"建议:把购物车里多个单品打包成套餐 —— 这才是真正的大头

修复效果(真实数据):

只买一个套餐      → 可省 ¥0.00     (旧逻辑会错报 ¥11.50)
单点巨无霸+可乐   → [打包] 省 ¥3.00
单点三样          → 单点 ¥48.50 → 套餐 ¥33.50

三条铁律现在都有回归测试盯着(★ 标记的那几条)。


四、顺手做的一个功能:麦当劳卡 ROI

菜单里有一类商品带 discountType="麦金卡优惠",同时给出 originalPrice 和 currentPrice。

商店里 114 个 SKU,15 个带卡价。价差最大的一项:

全明星双人分享餐八件套   ¥114.5 → ¥49.9   (省 ¥64.6)

有意思的是:卡费本身 MCP 查不到。

所以我没有去猜"值不值",而是输出一个不需要假设、可以直接验证的结论:

盈亏平衡卡费 ¥26.50 —— 只要卡费低于这个数,你这一单就已经回本。

这比"建议你买卡"有用得多,因为它把判断权交回给了用户。


五、Demo 和代码

零 Token 的单文件 demo(内置真实门店数据快照,用的是同一套算法,点开就能玩):

👉 monekey.github.io/mcd-meal-op…

源码(Node,零第三方依赖,27 条断言测试):

👉 github.com/Monekey/mcd…

core/        纯函数:菜单归一、组合求解、卡 ROI
server/      MCP 客户端(含 SSE 解析、session 管理、空返回重试)+ 解析层
web/         移动端前端(零构建)
docs/        离线 demo(构建时把 core + 数据内联成单文件)
tests/       27 条确定性断言

小结

如果只记一句话:接口返回不稳定、字段名不一致、文档不全,这些都不是最麻烦的; 最麻烦的是"静默失败" —— 传错字段给你 price: 0,调用失败给你空字符串,都不报错。

所以我在客户端里加了一堆"异常检测":核价返回 0 就是异常,营养返回空就是异常, 不假设任何一次调用会成功。