引子:大家都去挤省钱赛道,我翻了翻文档
这次大赛主题是「基于麦当劳官方 MCP 能力开发 Skill」。我扫了一圈已经提交的作品,绝大多数扎在点餐省钱 / 一键领券上。
然后我去看官方给的 35 个工具清单,发现一个官方自己写明了用途、却基本没人做的接口:
list-nutrition-foods:获取麦当劳常见餐品的营养成分数据......当用户咨询麦当劳餐品的热量、营养,帮助用户搭配指定热量套餐时有用。
官方明说能「搭配指定热量套餐」,但没人做。我的切入点就定了:别去挤红海,把官方提供但闲置的能力用透。
一、接入:标准 Streamable HTTP
Token 在 open.mcd.cn/mcp 手机号登录后「控制台 → 激活」获取。限流 600 次/分钟,401 表示 Token 失效,429 表示触发限流。
json
{
"mcpServers": {
"mcd-mcp": {
"type": "streamablehttp",
"url": "https://mcp.mcd.cn",
"headers": { "Authorization": "Bearer YOUR_MCP_TOKEN" }
}
}
}
可以直接用 curl 验证,不必依赖客户端:
bash
curl -X POST https://mcp.mcd.cn/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
⚠️ 端点是根路径
/,/mcp会返回 404。
返回 35 个工具,覆盖点餐、领券、积分、抽奖、派对预约等。
二、第一个坑:营养接口返回的是自定义文本格式
list-nutrition-foods 零参数,但返回不是规整 JSON,而是自定义文本格式:
csharp
[160]{productName,nutritionDescription,energyKj,energyKcal,protein,fat,carbohydrate,sodium,calcium}:
猪柳麦满分,null,1288,308,16,16,24,781,213
中薯条,null,1210,289,4,12,38,165,18
...
这段文本还被包在 MCP 响应的 content[].text 里,外面裹着一层字段说明。解析要做三件事:抠出真正的 JSON、反转义、按表头拆行。
核心正则:
python
HEADER_RE = re.compile(r"\[(\d+)\]\{([^}]*)\}\s*:")
# 1) 从说明文本里抽出 data 字段(内部有转义引号)
dm = re.search(r'"data"\s*:\s*"((?:[^"\\]|\\.)*)"', text)
data_str = dm.group(1).replace('\\"', '"').replace("\\n", "\n")
# 2) 按表头拆字段
hm = HEADER_RE.search(data_str)
fields = [f.strip() for f in hm.group(2).split(",")]
两个细节坑:
- 接口不返回分类字段,品类只能按餐品名推断。「冰美式小杯」名字里没有"咖啡",被误判成"其他"。
energyKcal可能为空,用energyKj × 0.239兜底。
解析器做成独立脚本 scripts/parse_nutrition.py,4 项自测覆盖格式解析、品类推断、kJ 兜底、脏行容错。
三、算法:三重约束下的组合优化
拿到 160 条数据后,真正的活是:在「热量上限 + 蛋白下限 + 预算上限」下求最优组合。
这是带约束的组合优化。餐品池上百个,暴力枚举会炸,所以做了三层剪枝:
python
def build_pool(items, constraints):
# 1) 单品淘汰:单品热量已超总上限的直接出局
pool = [it for it in items if it["energy"] <= max_cal]
# 2) 候选池裁剪:按蛋白密度排序,每类最多留 12 个,总池不超过 48
...
return pool[:size]
def search(pool, constraints, mode):
for size in range(1, max_items + 1):
for comb in itertools.combinations(range(n), size):
# 3) 累加剪枝:热量一旦超上限立刻换下一个组合
for idx in comb:
cal += pool[idx]["energy"]
if cal > max_cal: skip = True; break
另一个容易忽略的问题:营养指纹去重。
第一次跑通时,方案 3 比方案 2 多花 9 块买了一瓶 0 kcal 的零度可乐------营养完全相同,价格更贵。优化器看不出"多买了个没用的东西"。
按 (热量, 蛋白, 脂肪, 碳水) 做指纹,营养一致时只留最便宜的:
python
def dedupe_by_nutrition(results):
best = {}
for r in results:
t = r["total"]
fp = (round(t["energy"]), round(t["protein"]),
round(t["fat"]), round(t["carb"]))
if fp not in best or r["total"]["price"] < best[fp]["total"]["price"]:
best[fp] = r
return sorted(best.values(), key=lambda r: -r["score"])
支持四种模式:cut(减脂)、bulk(增肌)、cheap(省钱)、balanced(均衡)。所有脚本都带 --self-test,改动后必跑。
四、真实数据跑出来的两个结论
结论一:热量和钠是两个独立维度
只约束「低热量 + 高蛋白」时,最优解:
| 组合 | 热量 | 蛋白 | 钠 |
|---|---|---|---|
| 脆汁鸡×2 + 板烧鸡腿麦满分 | 524 kcal | 54 g | 1996 mg |
1996 mg 钠,成年人一天推荐上限才 2000 mg------一餐吃掉一整天的量。
加上 max_sodium: 1500 后:
| 组合 | 热量 | 蛋白 | 钠 |
|---|---|---|---|
| 脆汁鸡 + 板烧鸡腿炒双蛋堡 | 545 kcal | 46 g | 1465 mg |
钠降下来了,代价是蛋白从 54g 掉到 46g。这是真实的取舍,不是优化能消除的。
只看热量的"健康配餐"是不成立的。这也是本项目默认把钠和热量一并输出的原因。
结论二:营养表和门店菜单是两套不相交的数据源
想让热量和价格同时约束,就得把两边对齐。直觉按名字匹配------实测命中数是 0。
原因有两个:
list-nutrition-foods的 160 条是常见品静态清单,门店菜单前列全是当季新品(如龙焰系列),压根不是一套东西。- 就算重名,写法也不统一 :营养表写
纯牛奶(盒装)(全角括号),菜单写纯牛奶(盒装)(半角)。
有效的归一化三步:
python
def norm(s):
s = unicodedata.normalize('NFKC', s) # 1. Unicode 归一化
s = s.replace('(', '(').replace(')', ')') # 2. 全角/半角括号统一
return re.sub(r'\s+', '', s).lower() # 3. 去空格 + 转小写
处理后,83 条价格与 160 条营养对齐出 19 条。
这是从样例数据走向真实落地最关键的一个坑------本地造的样例永远碰不到,只有真接了才暴露。
五、门店与价格链路的 4 个坑
价格不在营养接口里,走另一条链路:
| # | 现象 | 原因 / 解法 |
|---|---|---|
| 1 | 600058 城市名或者关键词不能为空 |
query-nearby-stores 的 city 和 keyword 必须同时给 |
| 2 | 600057 门店可能已关闭或不在营业时间 |
打烊门店不给菜单;挑 businessStatus=true 的店 |
| 3 | query-meals 只有 code 和 tags |
名字要再调 query-meal-detail |
| 4 | query-meal-detail 只有 diffPrice |
基础价靠 calculate-price 实算(返回单位是分,÷100 得元) |
calculate-price 返回示例:
json
{
"productOriginalPrice": 2990, "productPrice": 2990,
"discount": 0, "price": 2990,
"productList": [{"productCode":"9900016075","productName":"龙焰芝士棒鸡腿堡三件套","quantity":1}]
}
六、端到端真实结果
深圳某营业中门店,19 个双源对齐餐品,约束「600 kcal / 蛋白 ≥ 30g / 必含堡 / ≤ ¥40 / 最多 3 件」:
| 方案 | 组合 | 热量 | 蛋白 | 钠 | 实付 |
|---|---|---|---|---|---|
| 1 | 薄皮焦香V翅 + 板烧鸡腿堡 | 583 kcal | 40 g | 1635 mg | ¥39.5 |
| 2 | 双层吉士汉堡 + 纯牛奶(盒装) | 558 kcal | 34 g | 1090 mg | ¥33.5 |
| 3 | 板烧鸡腿堡 + 纯牛奶(盒装) | 520 kcal | 30 g | 1114 mg | ¥34.0 |
三重约束全部满足。
配套运动换算用 ACSM 代谢当量公式,把抽象热量翻译成人话:
ini
kcal/min = MET × 3.5 × 体重kg / 200
583 kcal → 跑步 62 分钟 ≈ 8.2 km ≈ 10,977 步。
七、工程经验小结
- 先盘一遍官方给了什么,再决定做什么。 大家都挤同一条赛道时,翻文档往往能找到被闲置的能力。
- 自测要造"明知有问题"的输入。 14 项断言里,有 3 个 bug 是自测当场抓出来的(断言写在取整后的值上、品类推断漏关键词、0 热量商品凑数)。
- 端到端实跑比代码审阅有用。 方案 3 多卖一瓶可乐这种问题,看代码看不出来,跑一遍就现形。
- 真实数据源之间的对齐是最大成本。 名字、大小写、全半角、品类名目------这些脏活在原型阶段永远看不到。
- MCP 只负责给真实数据,计算放本地。 组合优化和运动换算都在本地脚本里,离线可验证,也不受接口限流影响。
八、开源 & 求个 Star
项目:麦门卡路里精算师(mcd-calorie-architect)
- 三个脚本:
meal_planner.py(约束求解)、burn_calc.py(运动换算)、parse_nutrition.py(MCP 数据解析) - 均带
--self-test,共 14 项断言,零依赖(纯标准库) - 实测记录:
examples/live-run-2026-10-10.md
这是 2026 麦当劳程序员创意开发大赛的参赛作品(用腾讯云 WorkBuddy 开发)。如果你也在参赛、或者也在折腾 MCP,欢迎去看看,觉得有参考价值点个 Star ⭐,也欢迎在 Issue 里交流配餐算法。
本文数据为 2026-10-10 接口实测快照,餐品/营养/价格以麦当劳官方实时结果为准。项目非麦当劳官方产品。