麦当劳中国 MCP 的 12 条实测契约:官方文档没写的那些坑

14 阅读8分钟

麦当劳中国 MCP 的 12 条实测契约:官方文档没写的那些坑

麦当劳中国开了官方 MCP(`mcp.mcd.cn`)。他们今年办了个开发者比赛,我参加的作品要调这套官方接口,所以把这 7 个工具都过了一遍。文档只给了工具名和参数名,剩下的事得自己撞,下面这 12 条是撞出来的。

速查

  1. 端点不带 /mcp 后缀,加了反而 404
  2. 响应是「散文说明 + 内嵌 JSON + 补充说明」拼起来的混合文本,不能直接 json.loads
  3. 营养库返回的不是 JSON,是一段紧凑文本
  4. 营养库里没有任何糖字段,只有碳水。碳水 ≠ 糖
  5. query-meals 的 currentPrice 是字符串,单位「元」
  6. calculate-price 的 price 单位是「分」,跟上面那个正好相反
  7. meals 是字典,key 是餐品 code,不是数组
  8. 非营业时段:query-meals 报 600057,calculate-price 报 60003,逃生口都是 reservationDate
  9. orderType=1 时不能传 beCode,传了直接报错
  10. searchType=2 必须 city 和 keyword 一起给,只给 keyword 报 600058
  11. query-meals 不返回店名,也没有按 storeCode 反查店名的工具
  12. 下单接口的 takeWayCode 文档里没有,得从 calculate-price 的 takeWayList 里拿

下面挑几条展开。

三种响应格式,只有一种是 JSON

json.loads 在这个接口上不管用。返回的是人话说明、内嵌 JSON、补充说明三样拼起来的文本:

查询成功,以下是门店信息。
{"stores": [...]}
以上结果仅供参考。

所以解析层得先从散文里把 JSON 薅出来:找第一个 { 到最后一个 },再试着解析,然后才谈取字段。解析失败要有降级路径,把原始文本回显出来,别抛异常。

营养库那个更意外,返回的压根不是 JSON:

[160]{productName,nutritionDescription,energyKj,energyKcal,protein,fat,carbohydrate,sodium,calcium}:
  猪柳麦满分,null,1288,308,16,16,24,781,213
  双层吉士汉堡,null,1796,429,27,22,31,1017,232
  巨无霸,null,2151,513,27,25,43,961,171

三个隐含约定。方括号里的 [160] 是声明条数,别当行数用。字段顺序由表头声明,不能按位置硬编码,表头一变你的代码会静默错位。字段数跟表头对不上的行直接丢掉,别想办法对齐。

再往下还有个 null。它要转成 None,不能转成 0。把不知道变成零,后面所有除法都会算出一个看着很正常、其实是编出来的结果。这种"看着正常"的错值比报错难查得多,所以解析失败的行、明显异常的值,我现在的做法是标出来但不抹掉,宁可让界面上多一行"这条可疑"。

一个用元,一个用分

"25.5" 和 3500 说的是同一件事,前者是 25.5 元的巨无霸,后者是它的 35 元。两个字段分别来自 query-meals 和 calculate-price,一个用元一个用分,一个是字符串一个是数字。

这两条是整套东西真正卡我的地方。代码不难写,难在漏掉任何一边的换算它都不报错:差 100 倍,流程照跑,日志干干净净,只有最后给用户看的那个数字不对。等你发现的时候,已经分不清是数据错了、逻辑错了,还是换算错了。

处理办法很土。后来我把这两个字段直接改名了,price_cents 和 price_yuan,让单位长在名字里,因为同一次下单里菜单给你的是 25.5 元、计价给你的是 3500 分,中间那层转换只要漏掉,整条链路上不会有任何地方提醒你,你只会看到一个合理得可疑的数字。类型转换也只留在适配层这一处地方。单位不确定的时候先打印原始值,打一行 log 三十秒能看出来,靠推理能找半小时。

顺便一句,要单项单价用 productList[].subtotal,别拿总价除件数。门店活动价不是线性的,除出来的单价是错的。

打烊之后

凌晨调菜单返回 600057,调计价返回 60003。看到错误码容易以为参数写错了,其实是门店打烊了。文档没提这一层。

逃生口是 reservationDate,格式 yyyy-MM-dd HH:mm:

reservationDate = "2026-10-12 12:00"

加上它,凌晨也能拿到菜单和价格,因为它按预约到那个时间点算。凌晨要取数,只有这一条路。链路里有定时任务的话,这个参数得当成必需项。

店名

query-meals 只吃 storeCode,不返回店名,而且没有工具能按 storeCode 反查店名。

唯一拿得到店名的地方是 query-nearby-stores,它同时返回 storeCode 和 storeName,两种模式:searchType=1 是收藏门店列表,只需要 beType;searchType=2 按位置搜,必须 city 和 keyword 一起给,只给 keyword 会报 600058。

所以顺序是先定门店拿到 code 和 name,再带着 code 去查菜单。反着来就断了。

为什么值得较真:同一个巨无霸,北上广 25.5 元,深圳 26.5 元。一块钱的差价能让每元蛋白从 1.06 掉到 1.02,排名掉一名。门店错了,结论就是错的。

实在拿不到店名,宁可不显示门店,也别把 storeCode 印给用户,那串编号对他没有意义。

两份数据对不上

营养库是全国一张固定的表,菜单是每家门店各自实时的,两边不同步。所以同一套代码,在这家店能参与计算的餐品是这些,换一家店可能就少几款,而这件事不把两份数据都拉下来做一次交集,是看不出来的。

我们在北上广深四城、七家门店跑过同一套计算:

  • 门店在售 100 款左右(实测 100~106)
  • 营养库 160 项
  • 对得上的约 20 款,也就是 20%

没对上的那 80 款里,几乎包含全部甜品。新地、麦旋风、香芋派、菠萝派都在菜单上、都能点,营养库里没有对应条目。

还有一点容易被忽略:营养库里没有糖字段,只有 carbohydrate。淀粉也算碳水,所以含糖量从这个接口算不出来。有人问这个多少糖,答算不了比编一个数好。

匹配要两级。先精确同名,再剥掉规格后缀试一次,菜单写「纯牛奶(盒装)」、库里写「纯牛奶(盒装)」,括号全半角不一样。两级都失败就排除,不猜不估。这条我没有别的办法,接口不给就不给,宁可少推荐几款,也不拿估算值把列表填满。

下单接口的几个隐藏约定

takeWayCode 不在文档的参数表里。堂食还是外带,得从 calculate-price 响应的 takeWayList 里捞。如果只按文档给的参数写,你会在"堂食还是外带"这一步发现少一个必填项,而它的取值来源在另一个接口的响应里,中间隔着一层计价结果,也就是说你得先把这一单算一遍价,才能知道下单时要传什么。

到店自取不需要 beCode 和 addressId,传了会报错。orderType=1 时传 beCode 也会直接报错。这两个是外卖场景的参数,别顺手带上。

支付时限只有 15 分钟,在 orderDetail.expirePayTime 里。用户回头再点那个链接已经失效,所以回传的时候要把时间一起给他,否则你会收到链接打不开的反馈,而问题出在超时。

最需要小心的是这个:下单成功也可能没有 orderId。正常情况下响应里有 orderId、payH5Url 和支付时限三样,但如果解析不到 orderId,不能当成失败,订单可能真的创建了。这时应该报状态未知,让用户去官方 App 核对。一笔没有订单号的真实订单,比一个报错危险。

支付链接还要验域名,不在 mcd.cn 系就要提示。链接本身绑定单笔订单,只回给下单的人,不能放进任何可分享的产物。

另外,MCP 没有支付能力,它只创建待支付订单并返回链接,付款在官方 App 或小程序里完成。边界清楚,所以最坏的情况也就是有人诱导 agent 下了一单,不付款 15 分钟就失效。

这 12 条是在给一个麦当劳点餐的 Skill 做数据层的过程中逐条攒下来的,数字来自四城七店的实际调用。不过有几条我现在也只能绕着走:营养库跟菜单对不上的那 20%,我补不了,只能把缺口标在界面上;含糖量同样算不出来,只要接口不给糖字段,这个问题就一直在那儿。

所以界面上一直留着那一行:本店 100 款在售,有营养数据的 20 款。剩下 80 款为什么不在推荐里,就写在它旁边。