Shopify 把 10 亿商品开放给任意 agent 了:我用零依赖 Python 撸了个带预算闸门的代购网关

背景:这次变的不是"开放",是取消了合作制

先把时间线摆清楚,不然容易看错重点。

9 月,Shopify 和 Meta 签了个合作,让 Muse agent 能代用户在 Shopify 上逛和买。那是个模板:一个 agent,一次谈判,一套结账流程。

10 月 13 日,Shopify 宣布购物者可以让任意 agent 替自己买,覆盖 10 亿+ 商品,含 Shopify 站内和站外的 listing。配置入口是 shop.md,技术上是 Shop MCP,地址 mcp.shop.com。模板作废了。

官方给的接入方式有两种:一是把 https://mcp.shop.com/skill.md 丢给你的 coding agent 让它自己读;二是把 mcp.shop.com 当成 MCP server 加进 agent 配置。一次连接给 17 个工具,主要分四组------

  • 搜索与商品详情:search_catalog / lookup_catalog / get_product
  • 购物车与结账:build_cart → create_checkout → complete_checkout
  • 非 Shopify 店铺:购物者在 Shop 里批准,agent 拿到一次性卡信息去店铺自己的结账页付
  • 订单:search_orders 查订单和物流

搜索不需要 API key,这点我一开始没敢信,实测(读文档确认)确实如此:要更高额度才去 Dev Dashboard 建 key。官方还给了个 CLI,装完直接 shop search "ceramic coffee dripper" 就能看数据结构,不用写一行代码。

技术要点:真正要自己写的其实是四道闸门

MCP 那层没什么好讲的,接就完了。麻烦的是规则层。我读了一遍 Shop 帮助中心的个人 agent 页面,把关键约束抽出来,写成四条:

1. 逐笔批准是默认值。 官方明确说每笔购买都要人批准。这个默认值本身就是信号------连 Shopify 自己都不打算让 agent 自主下单。

2. 预算是可选的、分层的。 开了"允许 agent 替我付款"之后,可以设周额度、月额度或总额度。三个是叠加关系,谁先撞线算谁。

3. 非 Shopify 店铺走一次性凭证。 这时候不用你的真实卡,而是 token 或一次性虚拟卡,账单上显示成 SHOPPAY*[店名]。

4. 数据是单向的。 断开连接能阻止后续访问,但官方原话是"不会删除已经共享出去的信息"。而且信息到了第三方 agent 那边之后,按对方的条款和隐私政策走。

可运行示例:一个带闸门和审计的代购网关

下面这份是我照着上面的规则写的原型,纯标准库,Python 3.9+ 直接跑,不需要 pip 装任何东西。它不联网,目录是写死的五条,用来验证规则逻辑本身。

python 复制代码
# -*- coding: utf-8 -*-
"""
Shop MCP 式 agent 购物网关原型(零依赖标准库)
参照 Shopify 2026-10-13 的规则:
  - 目录 10 亿+ 商品;search 免密钥;17 个工具
  - 每笔默认要人批准;可选"允许 agent 代付"+ 周/月/总额度
  - 非 Shopify 店铺用一次性虚拟卡在店自己结账页付款
  - 上线无 webhook,只能轮询 get_checkout
"""
import time
import uuid

# ---------- 1. 迷你目录 ----------

CATALOG = [
    {"id": "p-101", "title": "陶瓷手冲滤杯 02 号", "price": 168.0, "store": "shopify", "stock": True, "ship_days": 3},
    {"id": "p-102", "title": "同款滤杯(第三方站)", "price": 149.0, "store": "offsite", "stock": True, "ship_days": 9},
    {"id": "p-103", "title": "磨豆机 手摇 不锈钢", "price": 899.0, "store": "shopify", "stock": False, "ship_days": 0},
    {"id": "p-104", "title": "电子秤 0.1g", "price": 259.0, "store": "shopify", "stock": True, "ship_days": 5},
    {"id": "p-105", "title": "滤纸 100 张", "price": 39.0, "store": "shopify", "stock": True, "ship_days": 2},
]

def search_catalog(q, limit=5):
    ql = q.lower()
    hits = [p for p in CATALOG if any(k in p["title"].lower() for k in ("滤杯", "磨", "秤", "滤纸")) and ql.strip() != ""]
    hits = hits or CATALOG
    return sorted(hits, key=lambda p: (not p["stock"], p["price"]))[:limit]

# ---------- 2. 预算闸门 ----------

class Budget:
    """Shop 的规则:周 / 月 / 总额度三选一或叠加,谁先撞线算谁。"""
    def __init__(self, weekly=None, monthly=None, total=None):
        self.limits = {"weekly": weekly, "monthly": monthly, "total": total}
        self.spent = {"weekly": 0.0, "monthly": 0.0, "total": 0.0}

    def check(self, amount):
        for k, lim in self.limits.items():
            if lim is not None and self.spent[k] + amount > lim + 1e-9:
                return False, "%s 额度 %.0f,已用 %.0f,这笔 %.0f 会超" % (k, lim, self.spent[k], amount)
        return True, "ok"

    def commit(self, amount):
        for k in self.spent:
            self.spent[k] += amount

# ---------- 3. 一次性虚拟卡 ----------

def issue_single_use_card(amount, store):
    import hashlib
    h = int(hashlib.sha256(store.encode("utf-8")).hexdigest()[:8], 16)
    return {"pan": "4242 **** %04d" % (1000 + h % 8999),
            "cap": amount, "desc": "SHOPPAY*%s" % store.upper(), "used": False}

# ---------- 4. 审计日志 ----------

class AuditLog:
    def __init__(self):
        self.rows = []

    def add(self, action, **kw):
        kw["action"] = action
        self.rows.append(kw)

    def dump(self):
        return self.rows

# ---------- 5. 结账(无 webhook,只能轮询) ----------

def create_checkout(cart, shopper):
    return {"id": "chk_" + uuid.uuid4().hex[:8], "status": "pending",
            "cart": cart, "total": round(sum(i["price"] * i["qty"] for i in cart), 2)}

def get_checkout(chk):
    """真实环境里状态是异步推进的,这里用轮询次数模拟。"""
    return chk

def poll_until_done(chk, max_polls=6):
    polls = 0
    seq = ["pending", "pending", "processing", "processing", "completed"]
    for st in seq:
        polls += 1
        chk["status"] = st
        if st == "completed":
            break
        if polls >= max_polls:
            break
    return chk, polls

# ---------- 6. 跑一遍 ----------

audit = AuditLog()
budget = Budget(weekly=600.0, monthly=1200.0, total=3000.0)
agent_rules = {"only_fast_ship": False, "approve_each": True}

def buy(title, qty, human_approve=True, offsite=False):
    item = next((p for p in CATALOG if p["title"] == title), None)
    if item is None:
        audit.add("reject", reason="not in catalog", title=title)
        return None
    if not item["stock"]:
        audit.add("reject", reason="out of stock", title=title)
        return None
    amount = item["price"] * qty
    if agent_rules["approve_each"] and not human_approve:
        audit.add("reject", reason="未获人工批准", title=title, amount=amount)
        return None
    ok, why = budget.check(amount)
    if not ok:
        audit.add("reject", reason=why, title=title, amount=amount)
        return None

    card = None
    if offsite or item["store"] == "offsite":
        card = issue_single_use_card(amount, "thirdparty-store")
        audit.add("issue_card", pan=card["pan"], cap=card["cap"], desc=card["desc"])

    chk = create_checkout([{"title": title, "price": item["price"], "qty": qty}], "shopper@example.com")
    chk, polls = poll_until_done(chk)
    chk["polls"] = polls
    budget.commit(amount)
    audit.add("order", title=title, amount=amount, status=chk["status"],
              polls=polls, paid_by=("single-use card" if card else "Shop Pay"))
    return chk

print("=== Shop MCP 式 agent 购物网关原型 ===\n")
print("搜索 '手冲 滤杯' ->")
for p in search_catalog("手冲 滤杯"):
    print("  %-22s ¥%-7.0f %s 发货%d天" % (p["title"], p["price"], "有货" if p["stock"] else "缺货", p["ship_days"]))

r1 = buy("陶瓷手冲滤杯 02 号", 1)
r2 = buy("电子秤 0.1g", 1)                    # 259,周额度 500 内
r3 = buy("磨豆机 手摇 不锈钢", 1)              # 缺货
r4 = buy("陶瓷手冲滤杯 02 号", 2)              # 336,撞周额度
r5 = buy("同款滤杯(第三方站)", 1, offsite=True)
r6 = buy("滤纸 100 张", 1, human_approve=False)  # 没批准

print("\n--- 结果 ---")
for label, r in (("① 滤杯 x1", r1), ("② 电子秤 x1", r2), ("③ 磨豆机(缺货)", r3),
                 ("④ 滤杯 x2", r4), ("⑤ 第三方站滤杯", r5), ("⑥ 滤纸(未批准)", r6)):
    if r is None:
        print("  %-16s 未下单" % label)
    else:
        print("  %-16s %s  ¥%.0f  轮询 %d 次" % (label, r["status"], r["total"], r.get("polls", 0)))

print("\n--- 预算剩余 ---")
for k, lim in budget.limits.items():
    print("  %-8s 已用 ¥%-6.0f / 上限 ¥%.0f" % (k, budget.spent[k], lim))

print("\n--- 审计日志 (%d 行) ---" % len(audit.dump()))
for row in audit.dump():
    tail = " ".join("%s=%s" % (k, v) for k, v in row.items() if k != "action")
    print("  %-11s %s" % (row["action"], tail))

跑起来(python shop_gateway.py)的真实输出:

ini 复制代码
=== Shop MCP 式 agent 购物网关原型 ===

搜索 '手冲 滤杯' ->
  滤纸 100 张               ¥39      有货 发货2天
  同款滤杯(第三方站)             ¥149     有货 发货9天
  陶瓷手冲滤杯 02 号            ¥168     有货 发货3天
  电子秤 0.1g               ¥259     有货 发货5天
  磨豆机 手摇 不锈钢             ¥899     缺货 发货0天

--- 结果 ---
  ① 滤杯 x1          completed  ¥168  轮询 5 次
  ② 电子秤 x1         completed  ¥259  轮询 5 次
  ③ 磨豆机(缺货)        未下单
  ④ 滤杯 x2          未下单
  ⑤ 第三方站滤杯         completed  ¥149  轮询 5 次
  ⑥ 滤纸(未批准)        未下单

--- 预算剩余 ---
  weekly   已用 ¥576    / 上限 ¥600
  monthly  已用 ¥576    / 上限 ¥1200
  total    已用 ¥576    / 上限 ¥3000

--- 审计日志 (7 行) ---
  order       title=陶瓷手冲滤杯 02 号 amount=168.0 status=completed polls=5 paid_by=Shop Pay
  order       title=电子秤 0.1g amount=259.0 status=completed polls=5 paid_by=Shop Pay
  reject      reason=out of stock title=磨豆机 手摇 不锈钢
  reject      reason=weekly 额度 600,已用 427,这笔 336 会超 title=陶瓷手冲滤杯 02 号 amount=336.0
  issue_card  pan=4242 **** 1140 cap=149.0 desc=SHOPPAY*THIRDPARTY-STORE
  order       title=同款滤杯(第三方站) amount=149.0 status=completed polls=5 paid_by=single-use card
  reject      reason=未获人工批准 title=滤纸 100 张 amount=39.0

稍微解释几个结果:

③ 磨豆机 缺货,没下单。真实环境要靠 get_product 现查库存,搜索结果可能是缓存。

④ 滤杯 x2 被周额度拦了。日志里写的是"已用 427",再来 336 就撞 600 的周线。这里有个实现细节值得说:预算检查必须在批准之后、下单之前,顺序反了会出现"人已经批准了但额度不够"的尴尬。

⑤ 第三方站那单 走了分支:先 issue_card 发一张上限 149 的一次性卡,再用它付款。日志里 desc=SHOPPAY*THIRDPARTY-STORE 就是用户在账单上看到的那一行。

⑥ 滤纸 39 块没下单,原因是没拿到人工批准。

踩坑提示

  1. 上线没有 webhook。 这是文档里一笔带过、但对实现影响最大的一条。结账状态只能轮询 get_checkout。我这个原型里从 pending 推到 completed 走了 5 次轮询,不是一次完事。
  1. 轮询要设上限。 上面的 poll_until_done 有 max_polls,真实代码里必须带,否则某个卡在 processing 的单子会把你的 agent 挂死。
  1. 别把 passkey 和验证码交给 agent。 Shop 帮助中心专门警告过,只能通过 Shop 自己的授权页授权。
  1. 额度单位要想清楚。 周/月/总三个额度是同时生效的。
  1. 审计日志要记"为什么拒绝"。 上面的 AuditLog 里每一条 reject 都带了 reason。agent 代买出问题时,第一个被问的一定是"它为什么买这个",而不是"它买了什么"。

总结

Shopify 这次真正的动作不是技术,是把"哪个 agent 能买"的选择权从平台手里交回了购物者。对开发者来说接入门槛降到了近乎为零;对用户来说要自己管住预算和审批。

我自己最不确定的是争议订单怎么算。官方文档在这块几乎是空白的------agent 买错尺码、买贵了、买重复了,责任在谁?这个没答案之前,我不太敢给家里人开"允许 agent 替我付款"。

顺带一句:如果你要做类似的东西,先把审计日志写扎实,再去接 MCP。顺序反了会很难受。

相关推荐
Qlly2 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝2 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
花涧鹤2 小时前
学完循环我才发现,我写的不是死循环,是死机
后端
打工仔折腾 AI3 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95273 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
武子康4 小时前
ESP32 唤醒成功,离线命令为什么没有执行?
人工智能·后端·agent
挖掘狂人4 小时前
SQL 注入:一行代码如何撬开整个数据库
后端·sql·数据分析
今年下半年4 小时前
【短信发送】Spring Boot整合多种短信接口的实现(附源码)
java·spring boot·后端·sms·qcloudsms