我用麦当劳开放的 MCP,把那个最冷门的营养接口做成了真实配餐求解器

引子:大家都去挤省钱赛道,我翻了翻文档

这次大赛主题是「基于麦当劳官方 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。

原因有两个:

  1. list-nutrition-foods 的 160 条是常见品静态清单,门店菜单前列全是当季新品(如龙焰系列),压根不是一套东西。
  2. 就算重名,写法也不统一 :营养表写 纯牛奶(盒装)(全角括号),菜单写 纯牛奶(盒装)(半角)。

有效的归一化三步:

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 步。


七、工程经验小结

  1. 先盘一遍官方给了什么,再决定做什么。 大家都挤同一条赛道时,翻文档往往能找到被闲置的能力。
  2. 自测要造"明知有问题"的输入。 14 项断言里,有 3 个 bug 是自测当场抓出来的(断言写在取整后的值上、品类推断漏关键词、0 热量商品凑数)。
  3. 端到端实跑比代码审阅有用。 方案 3 多卖一瓶可乐这种问题,看代码看不出来,跑一遍就现形。
  4. 真实数据源之间的对齐是最大成本。 名字、大小写、全半角、品类名目------这些脏活在原型阶段永远看不到。
  5. 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

👉 github.com/Donglaiye/m...

这是 2026 麦当劳程序员创意开发大赛的参赛作品(用腾讯云 WorkBuddy 开发)。如果你也在参赛、或者也在折腾 MCP,欢迎去看看,觉得有参考价值点个 Star ⭐,也欢迎在 Issue 里交流配餐算法。


本文数据为 2026-10-10 接口实测快照,餐品/营养/价格以麦当劳官方实时结果为准。项目非麦当劳官方产品。

相关推荐
AI日报派送佬2 小时前
Ultralytics YOLO 模型训练技巧与最佳实践
人工智能·python·深度学习·yolo·机器学习·计算机视觉
zhaowangji2 小时前
mujoco仿真(机械臂推动正方体到指定位置)
人工智能·python
数据管道工2 小时前
解析一个老网站:GBK 编码、页面结构漂移与限流退避
python
只睡四小时2 小时前
AI 生成 PPTX:11 页课件编译出 429 个形状
javascript·python·pptx·ai生成ppt·ooxml
leisoo80973 小时前
股票筹码分布怎么用获利比例成本区间与集中度实战 IG50免费开源股票数据API接口
开发语言·jvm·数据库·python·开源
ControlM3 小时前
从官方 CDN 里扒出 TRAE (TraeCode) 历史版本安装包
python·逆向·trae
tianyuanwo3 小时前
Python 属性查找陷阱:从 `AttributeError: ‘X‘ object has no attribute ‘_children‘` 说起
python
程序猿阿森3 小时前
混合精度详解:FP16 vs BF16,Loss Scaling 与 PyTorch 实战
人工智能·pytorch·python
benchmark_cc3 小时前
A股、港股、美股日K如何拼接?处理不同交易日造成的时间轴错位
大数据·python·pandas·量化交易·股票数据·quantdash