摘要:接 MCP 协议时发现麦当劳官方没公开入参文档,于是把入参 schema 改成运行时向服务端索取;凑单用 0/1 背包 DP 求精确解;拆单承认是启发式,但把启发式限制在方案空间里,不让它碰金额计算。
关键词:MCP、Model Context Protocol、JSON-RPC、0/1 背包、贪心、浮点精度
和 AI 一起写了个 MCP 客户端,结果最难的不是协议
这篇文章是我在掘金发的第一篇技术文。之前一直在看别人的文章,看得挺爽,自己写才知道有多难。
这次的项目是我做的一个小工具,叫麦麦券务台(mcd-coupon-desk)。整个项目是我和 AI 一路配合写出来的 ------ 算法框架、协议实现、踩坑定位,AI 出了大力气,但决定和取舍基本是我自己做的。后面我会具体说哪些活儿交给了 AI、哪些我没让它碰。
项目是干啥的
麦当劳的优惠信息散在三个地方:门店菜单、账号券包、门店券包。你打开 App 看到一堆券,真正想问的其实是三件事:
- 我手上这几张券,今天、在这家店,到底哪几张能用?
- 剩下那些为什么不给用?过期了,还是没满门槛?
- 一张单用一张券,还是拆成两单各用一张更划算?
写代码之前我想了一遍,这三个问题里没有一个需要大模型。它们全是确定性问题,有唯一正确答案,可以被复算。
所以我给自己定了两条硬约束,这两条基本决定了后面所有技术选型:
数值与约束全部沉到确定性代码里,模型只负责听懂人话。 不引入任何第三方依赖 ,clone 下来
python xxx.py就能跑。
一、为什么没直接用官方 SDK
MCP(Model Context Protocol)官方有 Python SDK,pip install mcp 一行就能用。我最后还是自己写了一遍传输层,原因有三个:
- 评审和用户零安装成本。一个「clone 完就能跑」的项目,和一个「先配环境再跑」的项目,复现率差一个数量级。参赛项目的评委不一定有 Python 环境。
- 依赖只有标准库时,代码审计成本最低。谁来审这段代码都不用去翻第三方包的实现。
- 手写一遍,才真的看懂调用链。SDK 会帮你吞掉很多细节,而这次的坑恰好都在细节里。
MCP 的 Streamable HTTP 传输其实很简单,核心就三条:
- 所有请求都 POST 到同一个 endpoint,body 是 JSON-RPC 2.0;
- 请求头必须同时 声明
application/json和text/event-stream两种 Accept; - 服务端在响应头里下发
Mcp-Session-Id,后续每个请求都要原样带回去。
第二条是最容易踩的。只写 application/json 时,一个标准实现的服务端会拒绝你,而且报错信息通常不会提示你「你的 Accept 写少了一个」。
这段是 AI 写完我逐行 review 的,实际传输层加起来不到 60 行。
二、握手其实只有两步
python
def _post(self, payload: dict) -> list[dict]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream", # 两个都要
"Authorization": f"Bearer {self.token}",
"MCP-Protocol-Version": PROTOCOL_VERSION,
}
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id # 会话粘性
...
握手分两步,注意第二步是个通知 ,没有 id,也不期待响应:
python
def initialize(self) -> dict:
rpc_id = self._new_id()
payloads = self._post({
"jsonrpc": "2.0", "id": rpc_id, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "mcd-coupon-desk", "version": "0.1.0"},
},
})
result = self._unwrap(payloads, rpc_id) or {}
# 协议要求补一条 initialized 通知;它没有 id,服务端不回包
self._post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
return result
响应可能是 JSON,也可能是 SSE
同一个 endpoint,返回体可能是纯 JSON,也可能是 text/event-stream(一行行 data: {...})。标准里允许服务端二选一,客户端必须两种都能吃。
我没有去猜,而是两种格式都试,取第一个带 result 或 error 的报文:
python
def _extract_payloads(body: str) -> list[dict]:
body = body.strip()
if not body:
return []
payloads = []
if body.startswith("{"):
try:
payloads.append(json.loads(body))
except json.JSONDecodeError:
pass
if not payloads and ("data:" in body or "event:" in body):
for raw_line in body.splitlines():
line = raw_line.strip()
if not line.startswith("data:"):
continue
chunk = line[len("data:"):].strip()
if not chunk or chunk == "[DONE]":
continue
try:
payloads.append(json.loads(chunk))
except json.JSONDecodeError:
continue
return payloads
三、最关键的一步:入参 schema 运行时发现
这一节我觉得是整套架构里最值钱的,也是一开始完全没想到的。
麦当劳的 MCP Server 提供了 35 个工具,但官方没有公开完整的入参文档。如果按「先读文档、再把字段名硬编码进代码」的常规做法,会死在第一行 ------ 字段名根本不是能猜出来的。
举个例子,按城市查附近门店,直觉上只需要 city。实际上 city 单独传会返回错误码 600058。实测下来是:
python
# 必须同时给 city 和 keyword,缺一个就报 600058
client.call_tool("query-nearby-stores", {"city": "南昌", "keyword": "南昌"})
所以正确做法是 ------ 不硬编码,运行时问服务端要 schema:
python
def list_tools(self, refresh: bool = False) -> list[ToolSpec]:
"""发现服务端提供的全部工具。入参 schema 不做硬编码,运行时向服务端索取。"""
rpc_id = self._new_id()
payloads = self._post({"jsonrpc": "2.0", "id": rpc_id, "method": "tools/list", "params": {}})
result = self._unwrap(payloads, rpc_id) or {}
specs = [
ToolSpec(
name=t.get("name", ""),
description=t.get("description", "") or "",
input_schema=t.get("inputSchema", {}) or {},
)
for t in (result.get("tools") or [])
]
self._tools = specs
return specs
拿到 inputSchema 之后,按 schema 里的 required 和 properties 去回填参数:
python
@property
def required(self) -> list[str]:
return list(self.input_schema.get("required", []) or [])
@property
def properties(self) -> dict:
return self.input_schema.get("properties", {}) or {}
好处很直接:上游哪天把 storeCode 改名成 store_code,我的代码不会静默取到空值,而是能在 schema 对比里立刻发现差异。对方改字段,我的失效方式是可见的 ,不是静默的。
后面排查另外 6 个错误假设,主要也是靠 tools/list 返回的 schema ------ 很多「接口调不通」的问题,答案就在里面。
这条其实可以推广:凡是「文档不完整但接口自描述」的外部系统,正确做法都不是硬编码 + 靠文档,而是把 schema 当成运行时数据拉回来。字段变更就从「线上事故」降级成「一次本地断言失败」。
四、算法核心:凑单是「最小子集和 ≥ 门槛」
业务规则是这样的:一张「满 35 减 18」的券,门槛按单判定。用户购物车只有 30 元时券用不了,系统会告诉他「还差 5 元」。
那么问题来了:该加哪几件,才能凑到门槛又花最少钱?
这不是「随便加一件到 35 元」那么简单。购物车里有薯条、可乐、鸡块、甜筒,价格参差。抽象出来是一个标准问题:
给定一堆正整数的商品价格,求总价 ≥ 门槛 且 金额最小 的子集。
我一开始想用贪心(价格从大到小塞),但很快发现贪心不保证最优。门槛 35,可选价格 10、10、9、16:
| 方案 | 凑法 | 合计 | 判定 |
|---|---|---|---|
| 贪心降序 | 16 + 10 + 10 | 36 | 能用,但不是最优 |
| 精确解 | 16 + 9 + 10 | 35 | 刚好达标 |
差 1 元看着不多,但这类误差在拆单场景会被放大 ------ 用户是照着系统给的方案花钱的。
于是换成 0/1 背包 DP + 路径回溯:
python
def minimal_subset_reaching(units: list[Item], target_cents: int) -> list[Item] | None:
"""求总价 ≥ target_cents 的金额最小子集;无解返回 None。"""
if target_cents <= 0:
return []
total = sum(u.price_cents for u in units)
if total < target_cents:
return None
dp: list[int] = [-2] * (total + 1) # -2 = 不可达,-1 = 金额 0
dp[0] = -1
for idx, unit in enumerate(units):
price = unit.price_cents
if price <= 0:
continue
for s in range(total - price, -1, -1): # 倒序:保证每个商品只用一次
if dp[s] != -2 and dp[s + price] == -2:
dp[s + price] = idx # 只写一次,不覆盖
for s in range(target_cents, total + 1): # 从小到大找第一个可达和
if dp[s] == -2:
continue
chosen, cursor = [], s
while cursor > 0: # 回溯路径
idx = dp[cursor]
chosen.append(units[idx])
cursor -= units[idx].price_cents
return chosen
return None
两个设计点:
dp 存的是「首个凑出金额 s 的商品下标」,只写一次、不覆盖。 这样每个可达金额只留一条路径,回溯时不会绕圈,省掉了一个 parent 数组。
复杂度是 O(n × 总金额)。 真实点餐场景里单品最多几百件、总金额不会超过几百元(即几万分),毫秒级就出来了。哪天要处理金额上万的场景,才需要换 meet-in-the-middle。
五、拆单:一个集合划分问题,以及我为什么不硬解
上面那个 DP 是「给定一张券,找最省的凑法」。拆单要难一层:
有多张券,要把购物车拆成多笔订单,让尽可能多的券同时生效,怎么分最省?
「哪些商品分给哪张券」本质上是集合划分问题(set partitioning),完整枚举是指数级的,n 稍微大一点就爆炸。
我的选择是承认它是启发式,但把启发式的代价降到可忽略:换三种排序,各跑一遍完整的分配流程,取最优的那个。
python
orderings = (
lambda v: -v.coupon.discount_cents, # 优先吃大额券
lambda v: -(v.coupon.discount_cents / max(v.coupon.threshold_cents, 1)), # 优先吃高减免率
lambda v: v.coupon.threshold_cents, # 优先满足低门槛券
)
- 减免额降序:直觉是先把最狠的券吃掉。
- 减免率降序(减免 ÷ 门槛):实测里这条经常最优,它倾向先处理「性价比高但门槛低」的券,给后面的券留余量。
- 门槛升序:优先处理容易达成的券,避免大额券吃掉商品后小额券再也凑不出门槛。
关键在于:每一种排序下,「选哪几件去凑这张券」这个子问题,仍然由上面那个背包 DP 精确求解。
也就是说启发式只作用在「券的处理顺序」上,不作用在「凑单金额」上。输出的方案即使不是全局最优,也不会出现「金额算错」这种硬伤。
样例数据下的实际效果:
方案 订单数 小计 节省 到手价
拆单最优 2 ¥71.00 ¥35.00 ¥36.00 ← 最优
多券叠加 1 ¥71.00 ¥30.00 ¥41.00
单券最优 1 ¥71.00 ¥25.00 ¥46.00
原价直选 1 ¥71.00 ¥0.00 ¥71.00
同一个购物车,拆成两笔比合并下单多省 5 元。这个差额不是估出来的,是算出来的。
六、金额必须用整数「分」
这条像老生常谈,但我确实栽过。
浮点数在比价场景会产生 0.1 + 0.2 = 0.30000000000000004 这类误差。误差本身很小,问题在于它会让最优解判断出错 :两个方案实付差 1 分钱时,浮点比较可能给出相反结论,于是系统推荐了一个更贵的方案,而用户是照着这个建议下单的。
所以项目里所有金额一律用整数分存储和运算,只在最终渲染时才除以 100:
python
def to_cents(value) -> int:
"""真实价格是「元」的字符串,如 "48.5" → 4850。"""
return int(round(Decimal(str(value)) * 100))
注意这里是 Decimal(str(value)) 而不是 Decimal(value) ------ 用后者的话,0.1 在转 Decimal 之前就已经是一个二进制浮点数了,误差被继承下来,等于白用 Decimal。
测试里专门留了一组用例守这条线(test_solver.py::TestMoneyUnits)。
七、踩坑清单
这几条是真花了时间的,记下来给要接 MCP 的人:
1. MCP 的返回不一定是 JSON。 有些工具(比如「一键领券」)成功时返回的是一段自然语言说明文字 ,不是结构化 JSON。我最初的解析器直接抛异常,误判成调用失败 ------ 实际上券已经领到了。修法是把「空结果」和「真错误」分开:只有当返回里完全不含 { 时,才认定它是说明文字。
2. 别用正则从说明文字里抠 JSON。 正文里经常出现中文引号包着的花括号,正则会被提前截断。正确做法是括号平衡扫描 ------ 从第一个 { 开始,数着 { 和 } 配对找到真正的结尾。
3. argparse 的 parents 会共享 Action 对象。 我用 p_demo.set_defaults(offline=True) 给子命令设默认值,结果这个默认值被写进了共享的 Action,导致所有子命令都跑成了离线模式。换个不冲突的字段名就好了。
4. 券面价可能只存在于券名里。 接口返回的券对象没有独立的「面值」字段,9.9元中杯冰美式 的 9.9 只能从 title 里解析。而「免费脆薯饼」要靠中文关键词识别成 0 元。
5. 用 Optional[int] 而不是 0 表示「不适用」。 固定价券的 fixed_price_cents 字段,我一开始用 0 表示「不是固定价券」。但 0 同时也是「免费券」的合法值 ------ 于是「免费脆薯饼」被算成了「省 0 元」。改成 Optional[int],用 None 表示不适用,这两种语义才分得开。
6. 日期范围的分隔符和日期本身的分隔符是同一个字符。 2026-10-10 00:00:00-2026-10-16 23:59:59,第一个 - 是日期分隔符,中间那个是范围分隔符。按 - 直接 split 会得到 7 段而不是 2 段。
八、AI 到底干了什么活
写到这总结一下分工,因为有同学可能好奇:
AI 干的:MCP 传输层实现、JSON-RPC 封装、背包 DP 的代码骨架、单元测试的用例生成、踩坑时的日志分析和定位。
我没让 AI 干的,也是我认为这几件事不能交出去:
- 定架构。哪些逻辑沉到代码里、哪些交给模型,这条线是我划的。搞错了整个项目就废了。
- 定「宁可少算不虚报」的原则。未标互斥组的券默认按互斥处理,这是业务判断,不是代码问题。
- 金额一律用整数分。AI 有概率在某些地方偷偷用 float,得盯着改。
- 最后的方案取舍。三种拆单排序都跑一遍取最优、还是只保留一种,这个决定看的是我对业务的理解。
我的体感是:AI 在「把已知算法写对」这件事上非常可靠,能省掉大量样板劳动;但「什么才算对」这件事上它给不了答案,必须自己想清楚。这可能也是 AI 时代写代码最需要的能力。
九、跑起来是什么样
项目零依赖,demo 子命令内置了样例数据,不需要申请任何 Token 就能跑通全流程:
bash
git clone https://github.com/zjh-1024/mcd-coupon-desk.git
cd mcd-coupon-desk
python mcd-coupon-desk.py demo
要接真实数据,申请 Token 后设置环境变量:
bash
export MCD_MCP_TOKEN="你的Token"
python mcd-coupon-desk.py --store 1410419 plan --want "巨无霸,薯条,可乐"
真实接口实测:菜单、门店、价格、优惠券四条链路全部通。南昌门店 1410419 返回 110 项真实餐品;真实券「9.9元中杯冰美式」作用在真实商品「麦咖啡™美式 ¥11.90」上,算出的到手价是 ¥9.90,与券面价吻合。
49 项单元测试,python -m unittest discover -s tests 全绿。
十、小结
这次实现里我觉得最有价值的三条:
- 不确定的接口,让它自己告诉你怎么调。
tools/list返回的inputSchema比任何文档都准,而且会随上游变化自动更新。 - 能用精确解的地方就别用近似。 凑单这个问题规模极小,背包 DP 的代价可以忽略,换来的是「可以证明这是最优」。
- 近似要用在正确的位置。 拆单的券分配顺序是启发式,但每张券的凑单金额是精确解。把近似限制在「方案空间」而不是「金额计算」上,错误才不会传导到用户面前。
项目地址:https://github.com/zjh-1024/mcd-coupon-desk
零依赖、内置样例、无需 Token 即可复现。如果这套思路对你有用,点个 Star 支持一下,这是我第一篇文章,能有人看挺开心的🙏
本项目为「麦当劳程序员创意开发大赛」参赛作品,非麦当劳官方产品;价格与优惠以官方渠道实时结果为准。