和 AI 一起写了个 MCP 客户端,结果最难的不是协议

摘要:接 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 一行就能用。我最后还是自己写了一遍传输层,原因有三个:

  1. 评审和用户零安装成本。一个「clone 完就能跑」的项目,和一个「先配环境再跑」的项目,复现率差一个数量级。参赛项目的评委不一定有 Python 环境。
  2. 依赖只有标准库时,代码审计成本最低。谁来审这段代码都不用去翻第三方包的实现。
  3. 手写一遍,才真的看懂调用链。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 全绿。


十、小结

这次实现里我觉得最有价值的三条:

  1. 不确定的接口,让它自己告诉你怎么调。 tools/list 返回的 inputSchema 比任何文档都准,而且会随上游变化自动更新。
  2. 能用精确解的地方就别用近似。 凑单这个问题规模极小,背包 DP 的代价可以忽略,换来的是「可以证明这是最优」。
  3. 近似要用在正确的位置。 拆单的券分配顺序是启发式,但每张券的凑单金额是精确解。把近似限制在「方案空间」而不是「金额计算」上,错误才不会传导到用户面前。

项目地址:https://github.com/zjh-1024/mcd-coupon-desk

零依赖、内置样例、无需 Token 即可复现。如果这套思路对你有用,点个 Star 支持一下,这是我第一篇文章,能有人看挺开心的🙏

本项目为「麦当劳程序员创意开发大赛」参赛作品,非麦当劳官方产品;价格与优惠以官方渠道实时结果为准。

相关推荐
溪语流沙2 小时前
【Web全栈进阶】OAuth实战——GitHub第三方登录
前端·python·github
逛逛GitHub2 小时前
最近逛 GitHub ,挖到 3 个给力的开源项目,第一个比较硬核。
github
u1301302 小时前
GitHub 热榜项目:周榜(2026-10-11)
人工智能·驱动开发·github
WindrunnerMax2 小时前
以轻量 LoopAgent 替代 RAG 实现文档检索
人工智能·架构·github
做运维的阿瑞2 小时前
利用GitHub配置镜像加速,解决 Docker镜像拉取问题
运维·docker·容器·github
怕浪猫3 小时前
AI Agent 的安全怎么保障?面试最后一关的终极问题
算法·面试·github
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(97):MCMA——让 Agent 学会如何抽象记忆,实现跨任务经验迁移
论文阅读·人工智能·学习·开源·github
miofly4 小时前
GitHub 日榜趋势速报 | 2026-10-12
开源·github
高频因子挖掘机4 小时前
量化研究中,为什么拿到 Pandas 行情数据不等于完成数据接入?
后端·github·api