背景:这次变的不是"开放",是取消了合作制
先把时间线摆清楚,不然容易看错重点。
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 块没下单,原因是没拿到人工批准。
踩坑提示
- 上线没有 webhook。 这是文档里一笔带过、但对实现影响最大的一条。结账状态只能轮询
get_checkout。我这个原型里从 pending 推到 completed 走了 5 次轮询,不是一次完事。
- 轮询要设上限。 上面的
poll_until_done有max_polls,真实代码里必须带,否则某个卡在 processing 的单子会把你的 agent 挂死。
- 别把 passkey 和验证码交给 agent。 Shop 帮助中心专门警告过,只能通过 Shop 自己的授权页授权。
- 额度单位要想清楚。 周/月/总三个额度是同时生效的。
- 审计日志要记"为什么拒绝"。 上面的
AuditLog里每一条 reject 都带了reason。agent 代买出问题时,第一个被问的一定是"它为什么买这个",而不是"它买了什么"。
总结
Shopify 这次真正的动作不是技术,是把"哪个 agent 能买"的选择权从平台手里交回了购物者。对开发者来说接入门槛降到了近乎为零;对用户来说要自己管住预算和审批。
我自己最不确定的是争议订单怎么算。官方文档在这块几乎是空白的------agent 买错尺码、买贵了、买重复了,责任在谁?这个没答案之前,我不太敢给家里人开"允许 agent 替我付款"。
顺带一句:如果你要做类似的东西,先把审计日志写扎实,再去接 MCP。顺序反了会很难受。