关于 storefront-mcp
storefront-mcp 是面向电商商店的 MCP 服务模板,适用于希望向 AI Agent 开放商品目录、同时限制后台操作权限的团队。工具取决于适配器能力;公开店面操作与需令牌授权的敏感操作分离,写入操作带有保护措施。
主要功能
- 01提供商品目录工具
- 02区分公开工具与敏感工具
- 03凭凭据限制后台访问
- 04写入工具默认以干运行模式启动
- 05选择或实现目录适配器
- 06支持 stdio 和 HTTP
适用场景
- 01让 Agent 搜索商品并查看详情
- 02允许 Agent 发起受保护的结账
- 03仅向获授权调用方开放订单和销售数据
- 04通过适配器接入自有商品目录
MCP 相关
该服务向 MCP 客户端提供电商目录和运营工具。公开工具与敏感工具分开,敏感工具需授权;支持 stdio 和 HTTP 传输。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
npx -y storefront-mcpCATALOG_ADAPTERWhich catalog adapter to use: "memory" (bundled demo catalog, no setup) or "woocommerce" (Store API skeleton).MCP_SECRET密钥Unlocks the 5 sensitive back-office tools (stock, orders, sales). On stdio the presence of this variable in the server process env unlocks them; leave it unset to expose only the 6 public catalog tools.MCP_SERVER_NAMEOverride the advertised server name.本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
工具
6收录的工具列表 · 更新于 2 周前
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| limit | number | 否 | 最多结果数(默认值为 8,最多 25) |
| query | string | 是 | 用于搜索的自由文本(例如 "sketch marker warm gray") |
详细介绍
storefront-mcp
面向电商店铺的 MCP 服务模板。 AI Agent 可以获取你的商品目录;只有你能访问后台。
(Español más abajo / 西班牙语内容见下文。)
快速开始(30 秒)
npx storefront-mcp
这会启动一个通过 stdio 通信的 MCP 服务,提供演示商品目录(内置的 memory 适配器)和 8 个公开工具——6 个读取工具,以及两个默认以 干运行模式启动的写入工具:它们会运行所有检查,但不会创建任何内容。将以下配置添加到 MCP 配置文件(claude_desktop_config.json,或运行 claude mcp add storefront -- npx storefront-mcp),即可将其接入 Claude Desktop 或 Claude Code:
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"]
}
}
}
还想使用 5 个后台工具?stdio 没有 HTTP 标头,因此访问门槛是 MCP 服务进程环境中是否存在 MCP_SECRET——启动该进程的人拥有它运行所在的机器:
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"],
"env": { "MCP_SECRET": "anything-non-empty" }
}
}
}
更喜欢使用 curl?npx storefront-mcp --http 8787 会在 localhost 上通过普通 HTTP 提供相同的 JSON-RPC 契约,并进行真实的 Authorization: Bearer <MCP_SECRET> 检查(行为与下方的 Next.js 路由相同),还会启用 /api/stock-alert/confirm 确认页面:
npx storefront-mcp --http 8787 &
curl -s http://127.0.0.1:8787/ -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
使用 CATALOG_ADAPTER 选择适配器(默认为 memory,woocommerce 用于 Store API 骨架)。要提供自己的商品目录,请编写一个适配器(见下文)——CLI、Next.js 路由和注册表条目(server.json)都会复用同一套工具定义和权限边界。
这是什么
这是一个 Model Context Protocol 服务,以 Next.js App Router 路由的形式打包,可向 AI Agent(Claude、自定义 GPT、Agent 框架——任何支持通过 Streamable HTTP 使用 MCP 的工具)开放在线商店。它定义了 13 个工具,并会公布适配器实际能够响应的工具子集:
| 公开读取(无需认证) | 公开写入(受保护) | 敏感操作(Bearer 令牌) |
|---|---|---|
search_products | create_checkout | get_stock_bulk |
get_product | subscribe_stock_alert | get_top_products |
get_variant_chart | get_recent_orders | |
list_variant_charts | get_order_status | |
get_promotions | get_sales_summary | |
get_quote |
只有 search_products 和 get_product 始终可用。其他工具都是可选能力:实现适配器方法后,对应工具就会出现;不实现,该工具就不会出现在你的部署中——参见防护措施 18。
写入工具有意设为公开——让 Agent 代表人类购买正是其目的——因此它们依靠行为而非令牌来保护。它们默认以干运行模式启动。参见防护措施。
此项目从一个生产环境中的店铺服务提取而来,移除了所有特定于店铺的内容,并替换为简洁的适配器接口。
为什么
AI Agent 正在成为一种销售渠道。当有人向助手询问“帮我找一支附近有货的暖灰色酒精马克笔”时,胜出的店铺是那些 Agent 真正能够查询的店铺:结构化搜索、实时库存,以及带有支付链接的报价。公开的 MCP 端点能让你的店铺出现在这类对话中——使用你自己的域名和数据,并遵循你自己的规则。
核心设计:权限隔离
Agent 可以浏览店面橱窗,但绝不会看到后台操作。
每个工具都属于 公开 或 敏感 类别,并且会在协议层实施两次边界检查(src/lib/protocol.ts 由 Next.js 路由和独立 CLI 共用):
tools/list—— 如果没有有效的Authorization: Bearer <MCP_SECRET>标头,只会返回公开工具。敏感工具不仅会被锁定,还会完全隐藏。tools/call—— 即使调用方猜到了敏感工具的名称,也会在任何数据代码运行前收到 JSON-RPC 错误-32001。
此检查采用默认拒绝策略:如果未设置 MCP_SECRET 环境变量,所有人都无法使用敏感工具。不存在“未配置任何内容,所以一切都开放”的模式。令牌比较采用恒定时间算法。
传输方式的细节:通过 HTTP(Next.js 路由和 --http 模式)时,使用 Bearer 标头进行门控,因为远程调用方不受信任。通过 stdio(npx storefront-mcp)时没有标头——客户端和服务端共用一台机器——因此门控依据是服务端进程环境中是否存在 MCP_SECRET。边界相同,但会在各传输方式实际具备的信任边界处实施。
数据层也采用相同的划分:CatalogAdapter 接口只了解公开店面数据,而可选的 OpsAdapter(订单、营收、精确库存)是单独的契约,你完全可以不实现它——此时任何人都不会看到敏感工具。Ops 实现必须对客户 PII 进行匿名化:商品明细包含名称、数量和价格,绝不包含电子邮件、地址或电话号码,即使经过身份验证也是如此。
第二项设计:写入工具必须拒绝什么
读工具出错,只会提供不准确的信息。写入工具出错,则可能卖出你没有的库存,或把邮件炮筒对准陌生人——以机器的速度,在重试循环中,而且现场没有任何人。
因此,create_checkout 和 subscribe_stock_alert 的关键不在于它们做什么,而在于它们拒绝做什么;保护第三方的拒绝行为不可配置。你可以彻底关闭效果(干运行模式、紧急停用开关),但不能保留效果、移除检查。
下面每条防护措施之后都说明了缺少它会导致什么问题。这才是值得借鉴的部分——这些工具本身只有几百行,你一个下午就能写出来。
结账
1. 可用性依据库存来源检查,而不是目录。“已发布且可购买”和“有库存”是两个不同的问题,几乎所有电商技术栈都会由两个不同的系统回答(CMS 与 ERP/POS)。*缺少这项检查:*工具会根据可售商品目录索引解析每个商品条目,将其交给只了解价格的定价代码——整个流程中不会在任何地方查询库存。Agent 会订购你只有 2 件的商品中的 50 件,并成功生成一笔真实订单和可付款链接。
2. “我不知道”的处理方式与“没有库存”完全相同。可用性有三种状态:{units: n, verified: true}、{units: 0, verified: true}、{units: null, verified: false}。*缺少这项检查:*结果会被建模为一个数字,因此每种故障都会退化为 0(悄无声息地阻止实际销售),或“假定一切正常”(销售不存在的库存)。三种真正的“我不知道”情况——库存系统中没有映射该变体、库存快照中没有对应记录、后端宕机——没有一种等同于零。在向真人收费之前,未知状态和无货状态同样不能继续。
3. 在任何限额或库存检查之前,都会先按库存池汇总商品行,而非按名称汇总。 如果没有前半部分: 每行最多 50 件的限额就形同虚设,因为同一 SKU 的 20 行、每行数量为 50,总计就是 1,000 件,而每行都“符合”限额。如果没有后半部分——这也是简单去重容易遗漏的情况——{slug: "notebook-a4", qty: 50} 和 {sku: "NB-A4", qty: 50} 是两个不同的键,却指向同一商品和同一库存。每行都按同一批 60 件库存检查,因此都能通过,商店却卖出了 100 件。只有库存适配器能解析商品身份,因此 AvailabilityRow 包含 pool 字段,所有汇总限额都按库存池计算。如果适配器没有返回该字段,响应会明确说明,而不会假装这两行已被证实是不同商品。
4. 无效数量会被拒绝,绝不会被修正。
Math.min(Math.max(Math.floor(Number(qty) || 1), 1), 50) 看起来像是在清理输入。没有它: {qty: 0}——对 Agent 来说,这意味着“移除该商品”——会变成向真人收取费用的一件商品;负数、NaN 和小数也会变成凭空捏造的销售数量。在涉及金钱的工具中,清理输入意味着拒绝并说明原因;把输入改写成看似合理的内容,就是捏造意图。
5. 绝不接受调用方提供的价格,并且要核对报价是否与请求一致。 输入架构中根本没有价格字段;创建订单前,服务端会检查目录是否按请求的数量计算了价格,以及 unit_price × qty == line_total。没有前半部分: 折扣政策就由调用方随意填写。没有后半部分: 数量来自购物车,金额来自报价,却没有任何地方核对两者——于是某个“贴心地”把 40 件限制为 10 件的价格来源,会生成一笔按 10 件收费、却订购了 40 件的订单,而且其他所有防护措施都显示正常。报价可以拒绝某一商品行,但不能回答一个与所问问题不同的问题。
6. 订单创建前必须先锁定库存,否则实时结账就会被拒绝。 这是无状态检查无法做到的防护。第 1–3 点描述的都是过去:它们只是读取一个数字。十个并发调用都读取到“剩余 4 件”,都通过所有检查,也都创建了订单——实际售出 40 件,库存却只有 4 件,而且没有违反任何规则。只有在库存来源处执行原子递减才能避免这种情况,因此 InventoryAdapter.reserve() 会在检查之后、创建订单之前运行;如果部署环境中的适配器无法锁定库存,除非操作员设置 CHECKOUT_UNRESERVED=allow 并书面接受风险,否则不会创建实时订单。没有这项措施: 所有关于“防止超卖”的说法都只对一次一个请求成立,但这并不是“防止超卖”的真正含义。如果之后 createCheckout 失败,就会释放锁定的库存。
7. 拒绝请求时,同时提供证据。 无论成功还是失败,每一行都会返回 stock_available 和 stock_verified。没有它: 报告准确库存的工具受令牌权限控制(这是后台数据),因此公共 Agent 无法诊断问题,只能盲目重试,还会向用户编造原因。如果权限边界不允许 Agent 使用诊断工具,那么写入工具就有义务提供诊断结果。
8. 一行商品有问题,就会阻止整个购物车下单。 没有它: 用户收到的订单只包含“碰巧通过检查的商品”,但没人请求过这样的购物车。
9. 读给客户的金额,就是他们要支付的金额。 税费、运费和折扣由适配器处理,因此 CheckoutReceipt.total 可能与商品小计不同;出现这种情况时,响应会分别说明两者(amount_to_pay、charges),而不是返回两个互相矛盾的数字。否则: 只到 subtotal 为止的金额约定会默认价格含税且免运费。欧盟或美国的实现者要么在自己的后端加税,导致总额与同一响应刚报告的小计不符;要么不加税,造成少收费。
缺货到货提醒
10. 对外部产生影响的操作需要服务端业务前置条件。 只有产品确实缺货时才会发送邮件——已验证库存为零,或者在无法验证库存数量时,由商品目录独立确认缺货。否则: 一个可由调用方指定收件地址并发送邮件的公开工具,就是一门瞄准第三方的邮件炮。循环调用 tools/call,传入 {email: [email protected], slug: <anything>},成千上万封看起来完全合法的邮件就会从你的域名发出,消耗额度并损害发件人声誉,还会骚扰从未联系过商店的人。(额外一点:这也能杜绝对从未售罄的商品误发“已补货”提醒。)
11. 在发送邮件时去重,而不只是检查订阅——并在检查失败时默认拒绝。 这是两个不同的问题:“这个邮箱是否已经订阅?”以及“我们是否已经就这个商品给该邮箱发过邮件,却一直没有收到回复?”。缺少第二项: 第一项无法保护最关键的情形,因为攻击者永远不会确认——三次相同调用会发送三封邮件,而严格来说每封都不属于重复订阅。发送前要执行这两项查询;任何一项失败都不发送邮件:后端宕机绝不能被视为允许发送邮件。
12. 配额按收件人的邮箱地址计算,而且其可靠性取决于适配器。 每个邮箱每小时最多发送三封确认邮件,独立于调用方的任何限制进行评估。缺少邮箱归一化: 按原始字符串计数等于没有配额,因为同一个收件箱有无数种写法——victim@、victim+1@、victim+2@、v.i.c.t.i.m@、VICTIM@ 都会到达同一个 Gmail 账户,而每种写法都能各自获得全新的三封额度。缺少持久性: 内存计数器会在冷启动时重置、在多个实例间分散,并在重新部署时消失,因此这条上限只存在于纸面上。实现 NotifyAdapter.countOptInEmails,上限就会成为基于你存储中计数的实际限制;跳过它,工具自己的响应就会显示 quota_enforcement: "best_effort",而不会承诺它无法维持的数字。
13. 自动发送的邮件绝不会发往调用方指定的地址。 在网页结账流程中,你会给客户和管理员发送邮件。在 MCP 工具中,customer_email 是由 Agent 输入的。否则: 将“订单确认”接入写入工具,就会重新打开刚由提醒工具关闭的那门邮件炮。规则:作为工具输入传入的地址,只能收到一封双重确认邮件,不能收到其他邮件;其他邮件都必须先获得意向证明——而付款正是这种证明。(此模板不会向运营人员发送邮件。如果你想接收订单通知,请从自己的适配器发送到自己环境变量中配置的地址——绝不要发给 draft.customer_email。)
14. 双重选择加入需使用构造正确的令牌,且密钥长度必须足够。 v1.<payload>.<hmac>,过期时间必须包含在签名载荷中,比较时需防止时序攻击,签名密钥至少为 32 个字符;如果密钥缺失或过短,则应安全失败(工具应回答“opt-in unavailable”,而不是直接订阅)。否则: 未签名的令牌可被伪造;如果过期时间存储在令牌旁边而非令牌内部,就会被忽略;使用 === 比较签名会逐字节泄露签名;缺少环境变量会敞开大门;而 STOCK_ALERT_SIGNING_SECRET=x 虽然通过了“非空”检查,却让任何人都能为任意地址计算有效令牌并自行 POST——变成无人选择加入的双重选择加入。对外,“no secret”“malformed”和“bad signature”应共用一条消息;只有“expired”需要单独区分,因为这对用户有帮助,对攻击者却毫无用处。
15. 没有确认 URL,就不发送邮件。 否则: 这是一个其他环节都安全失败的流程中,唯一安全失效开放的地方。设置了签名密钥但未配置站点 URL 时,工具过去仍会发送邮件,确认链接指向 https://example.com——这是运营者并不拥有的域名——并在查询字符串中携带含有收件人本人地址的签名令牌,发送给第三方。链接失效已经很糟;失效链接指向别人的域名,还包含客户地址,则更糟。
16. GET 负责渲染,POST 负责写入——页面要说明正在确认什么。 确认页面在 GET 请求中不执行任何写入;只有在表单请求正文中携带令牌的 POST 请求才会持久化任何内容。否则: 企业邮件网关(Safe Links、URL Defense、桌面 AV、客户端预取)会在邮件送达时抓取每条消息中的每个链接。向受害者的地址发送确认邮件后,其雇主自己的安全扫描器就会激活订阅——你构建双重选择加入本想防止的第三方选择加入,又从后门溜了进来。扫描器会跟踪链接,但不会提交表单。这一原则适用于任何通过邮件链接触发的操作:确认、取消、批准、退订。页面还会标明产品和具体变体,因为订阅一个产品系列和订阅其中一种颜色,是不同的订阅;如果同意页面不说明这一区别,就称不上知情同意。写入操作具有幂等性,因此双击也只会成功,不会变成支持工单。
两种工具
17. 默认采用干运行模式,并为每种工具提供独立的紧急开关。 除非明确设为 live,否则 CHECKOUT_MODE 和
STOCK_ALERT_MODE 均为 dry;设为 off 则会将工具从 tools/list 中完全移除。干运行调用会执行每项检查,然后准确说明它会如何操作,包括“此操作本会被阻止,原因如下”。如果没有这一点: 部署后便会自动启用的写入工具没有演练机会——它第一次真正调用时,就会在生产环境中操作资金。只有精确的字符串 live 才会进入实况模式,因此拼写错误只会导致什么都不做。
18. 无法如实响应的工具不会被公布——这也适用于读取工具。 tools/list 从头到尾都受能力限制:没有 getQuote,也没有 get_quote;没有 OpsAdapter,即使调用者已获授权也不会提供后台工具;没有 CheckoutAdapter、InventoryAdapter 和定价能力,就不会提供 create_checkout。任何未公布的工具,无论出于什么原因,都会以相同方式返回 -32601。如果没有这一点: 你就会发布一些存根。WooCommerce 适配器过去曾实现三个它无法响应的方法——listBrands 返回 []、getColorCard 返回 null、getQuote 拒绝每一条明细——只是为了满足接口要求,而服务端还向每个匿名 Agent 公布了这三个工具:图表列表永远为空,查询结果永远是“未找到”,报价永远失败。让 Agent 两次走进死胡同,比根本不提供工具更糟。
19. 限制说明必须与实际限制一致。 错误信息会列出两种工具,并说明它们共用配额;工具说明也会保持一致,包括部署环境无法识别调用者的情况。如果没有这一点: 在两种工具间交替调用的 Agent 会比说明中更早碰壁,误以为计数器是按工具单独计算,然后重试——最终由限流机制制造出它本要阻止的流量。对于 MCP 服务而言,工具说明和错误字符串就是面向 Agent 的 API,而错误描述的限制最终会以重试为代价。
20. 限流键不能由调用者选择。 见下文。
关于该速率限制器(实话实说)
“按 IP 限流”通常有两个问题,第二个问题很少有人提及。
密钥。 人人都知道不能信任 x-forwarded-for 的第一项。容易被忽略的是:转发标头由代理写入;如果你前面没有代理——在 VPS 上运行 node server.js、直接运行 next start、nginx 未配置 proxy_set_header,或容器开放了公网端口——那么整个标头(包括最后一跳)都是调用方输入的字符串。只要轮换 X-Forwarded-For: 203.0.113.1, .2, .3…,每个请求就会创建一个新配额桶,限流器也就形同虚设。因此,除非你指定边缘代理写入的标头,否则此服务不会信任任何转发标头:
TRUSTED_PROXY_HEADER=x-vercel-forwarded-for # Vercel
TRUSTED_PROXY_HEADER=cf-connecting-ip # Cloudflare
TRUSTED_PROXY_HEADER=x-storefront-client-ip # the bundled WordPress proxy
指定标头意味着两件事:边缘代理会覆盖它,而且没有其他来源能访问源站。如果源站可以通过公网访问,找到它的人就能伪造该标头——请先锁定源站(部署保护、防火墙、mTLS)。如果未声明任何标头,--http 服务会使用 TCP 对端地址,没人能伪造这个地址;无服务器 Fetch 处理程序没有套接字可供查询,因此调用方会被视为无法识别——无法识别的部署会采用进程级上限,即每分钟 60 次写入,而不是承诺它无法兑现的按调用方限流。(这不是所有人共享 5 次/分钟:把所有调用方都塞进一个小配额桶,会让限流器变成针对自家客户的拒绝服务攻击,这比它要修复的问题更糟。)
存储。 随附的计数器是单个进程内存中的 Map。在无服务器环境中,这意味着N 个热实例 = N 个独立配额;冷启动会重置计数器,重新部署会将其清空。它只是用来阻止 Agent 循环的摩擦机制,不是 WAF,也不是滥用防护;src/lib/ratelimit.ts 中也明确标注了这一点,而没有把它说成团队实际上并不具备的上限。rateLimited(key, max, windowMs) 是一个简单的同步接口,调用方式固定,因此换成 Redis/KV/Durable Objects 只需机械式替换。在依赖该计数器处理任何事情之前,请先完成替换。
正因如此,真正重要的限制都附着在实际操作上:故障时拒绝服务、原子预留、缺货前置条件、发送时去重、双重选择加入,以及紧急停用开关。无论运行多少实例,这些都能生效,因为它们由数据而非计数器强制执行。每位收件人的邮件配额介于两者之间:如果你的 NotifyAdapter 实现了 countOptInEmails,配额就是持久的;否则只能尽力而为——工具响应会说明你得到的是哪一种,不会让你靠猜。
写入工具的配置
| 环境变量 | 默认值 | 作用 |
|---|---|---|
CHECKOUT_MODE | dry | create_checkout 的 off | dry | live 模式 |
STOCK_ALERT_MODE | dry | subscribe_stock_alert 的 off | dry | live 模式 |
CHECKOUT_UNRESERVED | refuse | 在 live 模式下,库存适配器无法预留商品时的处理方式:refuse(不创建订单)或 allow(接受并发调用可能导致超卖;每张回执都会说明这一点) |
STOCK_ALERT_SIGNING_SECRET | (未设置) | 选择加入链接的 HMAC 密钥,至少 32 个字符。缺失或过短 ⇒ 无法签发链接,工具会拒绝请求。openssl rand -hex 32 |
STOCK_ALERT_CONFIRM_URL | ${NEXT_PUBLIC_SITE_URL}/api/stock-alert/confirm | 确认页面所在位置。两者都未设置时,live 模式不会发送任何内容 |
STOCK_ALERT_PAGE_LOCALE | en | 该页面的语言(en | es)——客户能看到的唯一页面 |
TRUSTED_PROXY_HEADER | (未设置) | 边缘代理写入客户端 IP 的标头名称。未设置 ⇒ 忽略转发标头 |
只有在当前适配器提供相应功能时,才会公布工具,因此上述设置都不会让适配器无法履行的工具重新启用。
60 秒体验防护机制
随附的玩具商品目录经过设计,涵盖了所有状态。
chromaflow-classic-set-12 有 4 件;CF-G09 是已编入目录的变体,
但没有库存记录;fieldbook-sketch-a5 的发布状态为 in_stock,
但库存为零(这正是超卖场景);fieldbook-sketch-a4 则是普通情况——一件商品有 60 件库存,
既可通过其 slug 查找,也可通过其 SKU FB-A4 查找。
npx storefront-mcp --http 8787 &
# Consolidation + fail-closed: two lines of 3 for a product with 4 units.
# Per line each one "fits". Together they do not.
curl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_checkout","arguments":{
"items":[{"slug":"chromaflow-classic-set-12","qty":3},{"slug":"chromaflow-classic-set-12","qty":3}]}}}'
# → blocked, qty_requested 6, merged_from_input_lines 2, stock_available 4
# Same product, two spellings, one pile of units: 50 by slug + 50 by SKU
# against 60. Both lines "fit" on their own; the pool is what gets checked.
curl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{
"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_checkout","arguments":{
"items":[{"slug":"fieldbook-sketch-a4","qty":50},{"sku":"FB-A4","qty":50}]}}}'
# → blocked: "only 60 unit(s) available and 100 requested across the lines that
# share this stock", plus shares_stock_with on every line
# Unverifiable stock blocks like zero:
# "items":[{"sku":"CF-G09","qty":1}] → stock_verified:false, blocked
# Quantities are rejected, not repaired:
# "items":[{"slug":"fieldbook-sketch-a5","qty":0}] → items_invalid_qty, qty_received 0
快速入门:作为 Web 端点使用(2 分钟)
要通过自己的域名提供 MCP 服务(可部署的 Next.js 路由):
git clone <this repo> && cd storefront-mcp
npm install
npm run dev
就这样——默认的 memory 适配器会提供
examples/toy-catalog.json 中的玩具商品目录(一个虚构的商店“Demo Art Supply”)。试试:
# descriptor
curl http://localhost:3000/api/mcp
# list tools (public only — no token sent)
curl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# search
curl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_products","arguments":{"query":"leather dye"}}}'
# a sensitive tool without a token → -32001
curl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_sales_summary","arguments":{}}}'
# now with the token
export MCP_SECRET=$(openssl rand -hex 32) # also set it in .env.local and restart
curl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \
-H "authorization: Bearer $MCP_SECRET" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_sales_summary","arguments":{}}}'
要将其连接到 Claude Code:claude mcp add --transport http my-store http://localhost:3000/api/mcp。
准备部署?请将 TRUSTED_PROXY_HEADER 设置为平台写入的请求头
(Vercel 上为 x-vercel-forwarded-for,Cloudflare 后方为 cf-connecting-ip),否则
写入配额会成为整个实例共用的单一上限——参见
关于速率限制器:实话实说。
编写自己的适配器
协议层不会直接接触数据。它会调用 src/lib/adapter.ts 中定义的接口。必须实现两个方法。其他一切都是能力,而能力决定了哪些工具会启用:
CatalogAdapter(必需)—searchProducts和getProduct,仅此而已。根据定义,这些数据是公开的:假设它返回的每个字节都可被所有人读取。同一接口上的可选方法:getPromotions→get_promotionsgetQuote→get_quote(也是create_checkout的前置条件)listVariantCharts+getVariantChart→ 两个图表工具,要么一起启用,要么都不启用。“变体图表”指库存按变体记录的一条产品线——例如颜色、尺寸、粒度、烘焙度、容量。如果你的商品目录没有这类维度,就不要实现它们;没有需要打桩的方法。
OpsAdapter—getStockBulk、getTopProducts、getRecentOrders、getOrderStatus、getSalesSummary。启用受令牌保护的工具。InventoryAdapter—getAvailability(refs)返回每行的{units, verified, reason, pool}。这是“当前有多少件”的数据源,也必须与你的读取工具使用同一个数据源。它是可选的,但会影响核心功能:reserve(req)/release(id),缺少它们时,实时结账会被拒绝(护栏 6)。CheckoutAdapter—createCheckout(draft)。仅当同时提供InventoryAdapter和getQuote时才会启用。NotifyAdapter—isSubscribed/sendOptInEmail/confirmSubscription,以及可选的countOptInEmails,用于将每位收件人的配额变成实际上限。
只实现两个必需方法的适配器仍会按预期正常工作:其他功能根本不会启用。这是受支持的配置,而非降级配置。
步骤:
- 将参考实现
src/lib/adapters/memory.ts复制到新文件,并将其连接到你的数据库 / API / ERP。 - 在
src/lib/adapters/index.ts中注册,并通过CATALOG_ADAPTER环境变量选择它。 - 遵守契约中的诚实原则:
- 如果无法验证库存情况,则返回
units: null/stock: null——绝不编造数字,也绝不回退到 0; - 每条可用性记录都返回
pool:即该行所使用的库存池标识;产品 slug 和 SKU 必须映射到同一个库存池。否则,同一产品通过两种方式下单时,会针对同一库存重复检查; - 将
reserve实现为一次原子操作(UPDATE … WHERE (on_hand − reserved) >= :qty),必须全有或全无,并实现release以便回滚; - 所有库存答复都来自同一数据源,这样写入路径就不会依据与客户所见不同的信息进行验证;
- 扣除已在其他地方承诺的库存(未结束的预留、其他渠道)——“在货架上”不等于“可售给这位客户”;
isSubscribed失败时应抛出异常,而不是返回false;调用方会采取默认拒绝策略,不发送任何内容;- 为每次调用设置超时,以便后端挂起时降级为提示,而不是让 Agent 一直挂起;
- 确保
get_quote不会产生费用;让getQuote在每条定价记录中回传收到的slug/sku,并且按请求的数量定价,或者拒绝该记录——结账路径会比较两者,并在不一致时拒绝购物车。
- 如果无法验证库存情况,则返回
附带一个 WooCommerce 骨架(src/lib/adapters/woocommerce.ts),基于公开的 Store API 构建。它恰好实现了三个工具所需的商品目录功能(search_products、get_product、get_promotions),不会对任何内容进行打桩;TODO 代码块说明剩余各项契约需要 WooCommerce 安装提供什么,包括两个需要你自行决定的问题——按变体定义库存的方式,以及如何预留库存单位。
发现:让别人找到你
Agent 只能调用它们能找到的内容。两个工件的模板位于
discovery/:
/.well-known/mcp.json— 机器可读的描述文件 (discovery/well-known-mcp.json;替换{{DOMAIN}},并从public/.well-known/mcp.json提供)。其中只列出公开工具,并且只列出你的部署实际公布的工具。/llms.txt— 面向人类和 LLM 的网站指南 (discovery/llms-txt-snippet.md);其中包含 Agent 策略部分:完成销售前重新检查库存,报价不会扣款,stock: null表示未知,报价时使用amount_to_pay,而不是subtotal。
此外,GET /api/mcp 会返回一个 JSON 描述文件,让任何访问该端点的人都能了解它的用途。
对于官方 MCP Registry,仓库根目录中的
server.json 是清单:它指向使用 stdio 传输的 storefront-mcp npm 包,因此 Registry 客户端可以通过 npx 运行它。先发布到 npm——Registry 会验证已发布压缩包中的 mcpName,因此注册一个尚未发布到 npm 的版本会创建一个指向空处的条目。运行前,.github/workflows/publish-mcp-registry.yml 中的工作流会进行检查。
通过 MCP 使用技能
此服务还会通过协议提供使用说明,作为一项技能提供(SEP-2640,扩展 io.modelcontextprotocol/skills)。实现了该扩展的宿主会通过 skills/list 发现技能,通过 resources/read 读取 skill://storefront-mcp/SKILL.md,并验证条目中附带的 sha256 摘要。未实现该扩展的宿主会将同一个文件视为普通资源;不会造成任何问题。
该扩展基于协议修订版 2026-07-28 编写,因此服务现在会响应 server/discover 并协商该修订版(无状态,每个请求都会在 _meta 中标明版本,每个结果都带有 resultType,列表中包含 ttlMs/cacheScope)。较旧的客户端继续使用 initialize,协商 2025-06-18 或 2025-11-25,不会察觉任何变化(initialize 不会响应 2026-07-28:该修订版没有握手)。server/discover 响应还会携带指向该技能的 instructions,因此未安装该扩展的宿主(目前的 Claude Code)仍会在模型决定读取时将其作为资源读取。
支持 2026-07-28 也意味着拒绝该修订版要求服务拒绝的请求:_meta 中的协议版本不是此服务提供的版本(返回 -32022,并附带支持的版本列表)、缺少 clientCapabilities 的现代请求(返回 -32602),以及 HTTP 请求中缺失或与请求正文不一致的镜像请求头 MCP-Protocol-Version、Mcp-Method 和 Mcp-Name(返回 -32020,HTTP 400)。现代请求中的未知方法会返回 404。未标明 _meta 的请求(较旧修订版)仍按原方式处理。
SKILL.md 会根据部署的实际配置为每位调用者生成:其中会准确列出 tools/list 向该调用者公布的工具,说明每个写入工具当前的模式(dry 或 live),以及 Agent 的权限范围(未经明确购买请求不得结账;未提供专用地址不得发送提醒);对于操作员会话,还会列出其拥有的后台工具;匿名调用者的技能绝不会提及这些工具。因此,配置变更时,其摘要也会随之变化,这正是设计目的:固定了摘要的宿主会在此时重新读取该文件。
可使用支持该扩展的客户端试用(mcpc 0.7 或更高版本可通过配置文件连接,方式与任何 MCP 宿主相同):
cat > mcp.json <<'EOF'
{ "mcpServers": { "shop": { "command": "npx", "args": ["-y", "storefront-mcp"] } } }
EOF
npx -y @apify/mcpc connect mcp.json:shop @shop
npx -y @apify/mcpc @shop skills-list
npx -y @apify/mcpc @shop skills-get storefront-mcp
npm test 会构建 CLI,并端到端测试两种传输方式:已声明能力,skills/list 与 skills/get 内容一致,frontmatter 可解析为 YAML,且字段与条目相同(验证宿主必须如此),摘要和大小与实际提供的字节一致,技能名称与 tools/list 公布的工具完全一致(双向验证),且匿名调用者的技能绝不会提及后台。测试还涵盖上述 2026-07-28 拒绝规则,以及不是 JSON-RPC 请求的正文(null、数字、空批次);这些请求会返回 -32600,而不会执行。
从你的 WordPress 域名提供 MCP
如果你的店铺使用 WordPress/WooCommerce,但 MCP 服务部署在别处(例如 Vercel),wordpress-proxy/mcp-proxy.php 是一个 mu-plugin,通过代理到上游来提供 https://yourshop.com/api/mcp:
- 以优先级 0 挂接
init(在 WordPress 路由之前响应), - 原样转发 POST 正文和
Authorization标头(权限区分由上游执行), - 在
X-Storefront-Client-IP中转发真实客户端地址,并覆盖调用方发送的任何内容, - 处理 CORS 预检请求,收到 GET 时返回可读的描述信息,
- 将负载限制在 256 KB,
- 上游故障时返回 JSON-RPC 错误对象——绝不返回 HTML 错误页面,因为客户端是程序。
安装:将文件放入 wp-content/mu-plugins/,并在 wp-config.php 中定义
STOREFRONT_MCP_UPSTREAM。然后在 上游设置
TRUSTED_PROXY_HEADER=x-storefront-client-ip——否则,每个请求都会显示为来自 WordPress 服务的地址,整个店铺的写入配额就会变成每分钟 5 次调用,因此一个陷入循环的 Agent 就能让所有顾客都无法结账。只有在无法绕过代理直接访问上游时,才应信任此标头;如果上游可公开访问,任何找到它的人都能自行写入该标头。
为什么不直接用 Shopify 的 MCP?
如果你使用 Shopify:Shopify 已为每家店铺提供托管的 MCP 端点,其中带有通用的 search_catalog 风格工具,而且做得不错。直接用它。本模板适用于它无法满足的情况:
- 你不使用 Shopify——可能是 WooCommerce、自定义技术栈、无头架构,或者某个 2009 年的 ERP,不知为何至今仍在运行。
- **你的差异化优势来自平台永远不会生成的工具。**此处的示例是
get_variant_chart:产品系列的完整规格图表,包含每个规格的实时库存。任何店铺都能说“我们卖这些马克笔”;只有接入自有库存系统的店铺才能说“W3 色号现在有货,R21 色号没有”。这种针对每个规格的答复能促成销售,而且需要通用平台工具不具备的领域知识。 - 你想要权限隔离的后台——同一个端点配上令牌后,可以向你回答“本月哪些商品销量最高?”,同时只向 Agent 展示店铺橱窗。
- **你想要真正能防范风险的写入工具。**托管平台会替你决定其结账工具会检查什么。而在这里,拒绝逻辑就在你的代码库中,可供审查;保护第三方的规则也无法配置。
仓库布局
src/lib/protocol.ts protocol core (JSON-RPC, auth boundary, capability gating, dispatch)
src/app/api/mcp/route.ts Next.js transport (Streamable HTTP + Bearer)
src/cli/cli.ts standalone transport: `npx storefront-mcp` (stdio, or --http + confirm page)
src/lib/tools.ts tool definitions, built from what the adapter can answer
src/lib/adapter.ts the five adapter contracts + types
src/lib/commerce.ts create_checkout / subscribe_stock_alert — the guard rails
src/lib/availability.ts tri-state availability, pool identity, blocksWrite()
src/lib/cart.ts consolidate first, then measure limits; reject bad quantities
src/lib/money.ts currency-aware rounding (not everything has two decimals)
src/lib/email.ts address vs mailbox: canonical keys for quota and dedupe
src/lib/optin.ts signed double opt-in tokens (exp inside payload, fail-closed)
src/lib/optin-page.ts the confirmation page: GET renders, POST writes (framework-free)
src/lib/ratelimit.ts rateLimited(key,max,window) + what an in-memory limiter is NOT
src/lib/client-ip.ts caller identity: no forwarding header is trusted unless declared
src/lib/write-mode.ts off | dry | live per tool, + the unreserved-checkout policy
src/lib/errors.ts ToolCallError (quota / capability refusals)
src/app/api/stock-alert/confirm/route.ts Next.js transport for the confirmation page
src/lib/adapters/memory.ts reference adapter (all five contracts, one stock source, real holds)
src/lib/adapters/woocommerce.ts Store API skeleton — implements only what it can answer
src/lib/adapters/index.ts adapter registry (env CATALOG_ADAPTER)
examples/toy-catalog.json the demo data, incl. a separate `inventory` section
server.json MCP Registry manifest (registry.modelcontextprotocol.io)
tsconfig.build.json compiles lib + cli to dist/ for the npm bin
discovery/ /.well-known/mcp.json + llms.txt templates
wordpress-proxy/mcp-proxy.php mu-plugin to serve MCP under your WP domain
2.0 中的破坏性变更
get_color_card→get_variant_chart(参数brand→chart),list_brands→list_variant_charts。旧名称描述的是某家店铺的领域,而不是接口约定;负载中的colors更名为variants,hex更名为可选的swatch_hex。CatalogAdapter现在只要求实现searchProducts和getProduct。其他四个方法均为可选,每个方法都会控制对应工具是否启用。- 新增了
AvailabilityRow.pool。未返回该字段的适配器仍可正常工作,但无法检测 slug/SKU 冲突(防护措施 3)。 - 实际使用
create_checkout时必须有InventoryAdapter.reserve,除非设置CHECKOUT_UNRESERVED=allow(防护措施 6)。 - 除非
TRUSTED_PROXY_HEADER指定了某个转发标头,否则会忽略转发标头(防护措施 20)。 STOCK_ALERT_SIGNING_SECRET至少需要 32 个字符;实际使用的提醒现在还必须配置确认 URL。
许可证
Apache-2.0——参见 LICENSE 和 NOTICE。
storefront-mcp(西班牙语)
**面向线上店铺的 MCP 服务模板。**AI Agent 可以查看你的商品目录;只有你能查看你的运营情况。
30 秒内启动
npx storefront-mcp
这会通过 stdio 启动一个 MCP 服务,使用演示目录(memory 适配器),并提供 8 个公开工具:6 个读取工具,以及两个写入工具;写入工具默认以 dry 模式启动(运行所有检查,但不会创建任何内容)。要将它连接到 Claude Desktop
或 Claude Code,请将以下内容添加到 MCP 配置中(或运行
claude mcp add storefront -- npx storefront-mcp):
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"]
}
}
}
还想使用 5 个后台工具?stdio 中没有 HTTP 标头,因此关键在于服务器进程的环境中是否存在 MCP_SECRET(启动进程的人拥有运行该进程的机器):
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"],
"env": { "MCP_SECRET": "cualquier-valor-no-vacio" }
}
}
}
更喜欢用 curl?npx storefront-mcp --http 8787 会在 localhost 上通过 HTTP 提供相同的 JSON-RPC 协议,并进行实际的
Authorization: Bearer <MCP_SECRET> 检查(行为与 Next.js 路由相同),还会提供 /api/stock-alert/confirm 中的 opt-in 确认页面。适配器由 CATALOG_ADAPTER 选择(默认是 memory,woocommerce 对应 Store API 框架)。
这是什么
这是一个封装为 Next.js 路由(App Router)的 MCP 服务,可向 AI Agent(Claude、自定义 GPT、Agent 框架——任何使用 Streamable HTTP 的 MCP 客户端)开放在线商店。它定义了 13 个工具,并声明适配器实际能够响应的子集:
- 6 个公开读取工具:
search_products、get_product、get_variant_chart、list_variant_charts、get_promotions、get_quote。 - 2 个带防护规则的公开写入工具:
create_checkout和subscribe_stock_alert。 - 5 个受令牌保护的敏感工具:
get_stock_bulk、get_top_products、get_recent_orders、get_order_status、get_sales_summary。
只有 search_products 和 get_product 始终存在。其他所有内容都是一种能力:如果你实现了适配器方法,相应工具就会出现;如果没有,该工具就不会出现在你的部署中(见防护规则 18)。
写入工具特意设为公开——让 Agent 代人下单正是它的用途——因此它们依靠行为而非令牌来保护。它们默认以 dry 模式启动。请参阅 防护规则。
此项目提取自一个生产环境中的商店服务,其中所有特定于该商店的内容都已移除,并替换为适配器接口。
为什么
AI Agent 正在成为销售渠道。当有人对助手说“帮我找一支有库存的暖灰色马克笔”时,真正能被 Agent 查询 的商店更具优势:结构化搜索、实时库存,以及附有支付链接的报价。公开的 MCP 端点是在这场对话中现身的方式——使用你自己的域名、数据和规则。
核心设计:权限分离
Agent 可以看橱窗;永远看不到实际操作。
每个工具要么是公开的,要么是敏感的,协议层会将这一边界强制执行两次:
tools/list— 如果没有有效的Authorization: Bearer <MCP_SECRET>,只会返回公开工具。敏感工具并未被屏蔽:它们是不可见的。tools/call— 即使有人猜到敏感工具的名称,也会在任何数据代码运行前收到 JSON-RPC 错误-32001。
检查采用失败时默认拒绝的方式:如果环境中未设置 MCP_SECRET,所有人的敏感工具都会被阻止。不存在“我什么都没配置,所以一切都开放”的模式。令牌比较采用恒定时间算法。
传输方式上的细微差别:通过 HTTP(Next.js 路由和 --http 模式)时,密钥是 Bearer 请求头,因为外部调用者不受信任。通过 stdio(npx storefront-mcp)时,没有请求头——客户端和服务端共用同一台机器——因此密钥就是进程环境中存在 MCP_SECRET。这是同一个边界,只是在每种传输方式实际存在的信任边界处执行。
数据层也采用了同样的分离:CatalogAdapter 只了解橱窗公开数据,而 OpsAdapter(订单、销售额、精确库存)是单独的契约,你可以完全不实现它;在这种情况下,敏感工具不会向任何人公布。Ops 实现必须对客户信息进行匿名化:商品只包含名称/数量/价格,绝不包含电子邮件、地址或电话号码,即使是在身份验证之后也是如此。
第二项设计:写入工具必须拒绝什么
错误的读取工具会给出不准确的信息。错误的写入工具会卖出不存在的库存,或将电子邮件炮火对准第三方——以机器速度运行,在重试循环中不断执行,而且无人监看。
因此,create_checkout 和 subscribe_stock_alert 有趣之处不在于它们会做什么,而在于它们拒绝做什么;保护第三方的拒绝条件不可配置。可以关闭全部副作用(dry 模式、kill switch),但不能保留副作用却移除验证。
每项防护措施都说明了没有它会出什么问题。值得借鉴的正是这部分:工具本身不过几百行代码,谁都能在一个下午写出来。
结账
1. 可用性要根据库存核验,而不是根据商品目录。“已发布且可购买”和“有库存”是两个不同的问题,而几乎所有电商技术栈都会在两个不同的系统中回答它们(CMS 与 ERP/POS)。*没有这项检查:*工具会根据可售商品目录索引解析每一行,将其交给报价器——它只了解价格——整个流程中却没有一次库存查询。Agent 请求购买某商品的 50 件,而库存只有 2 件,最终却收到一个真实订单和可收款的支付链接。
2.“不知道”和“没有”同样要阻止操作。可用性有三种状态:{units: n, verified: true}、{units: 0, verified: true}、{units: null, verified: false}。*没有这项检查:*结果会被建模为一个数字,所有错误都会退化为 0(无声地阻止合法销售)或“假设有货”(出售空气)。三种真实的“不知道”情况——库存中没有映射的变体、快照中没有记录的商品、后端宕机——都不等于零。在向某人收款之前,未知和缺货的处理方式是一样的。
3. 在任何上限或库存检查之前,先合并各行;合并依据是单位池,而不是书写方式。 缺少前半部分: 每行 50 件的上限形同虚设,因为同一 SKU 的 20 行各填入数量 50,就是 1,000 件,而每行都“符合”上限。缺少后半部分——这也是简单去重无法解决的情况——{slug: "cuaderno-a4", qty: 50} 和 {sku: "CU-A4", qty: 50} 是两个不同的键,却指向同一个商品和同一堆库存。每行都根据同样的 60 件库存进行检查,两行都通过,商店却卖出了 100 件。只有库存适配器才能解析这种身份,因此 AvailabilityRow 带有一个 pool 字段,所有可汇总的上限都按单位池计算。如果适配器没有返回该字段,响应会如实说明,而不是假装已经证明这是两个不同的商品。
4. 数量无效时应拒绝,而不是修正。
Math.min(Math.max(Math.floor(Number(qty) || 1), 1), 50) 看起来像是在清理输入。缺少这条规则: {qty: 0}——对 Agent 来说表示“把这个移除”——会变成向某人收取一件商品的费用;负数、NaN 和小数也会变成凭空捏造的销售数量。对于会收费的工具,清理输入就是拒绝并解释;把输入改写成看似合理的值,就是捏造用户意图。
5. 价格绝不来自调用方,且报价必须与请求内容核对一致。 输入 schema 没有价格字段;创建订单前,服务端会验证目录报价的数量与请求数量一致,并确认 unit_price × qty == line_total。缺少前半部分: 你的折扣政策就由调用方随手填写。缺少后半部分: 数量来自购物车,金额来自报价,却没有任何机制将两者进行比较——因此,一个“好心”地把 40 件削减为 10 件的报价器,会生成一笔按 10 件收费、却订购 40 件的订单,而所有其他防护措施都显示正常。报价可以拒绝某一行,但不能回答一个与它收到的问题不同的问题。
6. 必须先预留库存,再创建订单;否则在线结账就应拒绝。 这是无状态检查无法实现的防护措施。第 1 至 3 点描述的是过去:读取一个数字。十个并发调用都读到“剩余 4 件”,十个调用都通过所有检查,并且都创建了订单:共卖出 40 件,却只有 4 件库存,而且没有违反任何规则。只有在库存来源处执行原子递减才能阻止这种情况,因此 InventoryAdapter.reserve() 会在检查和创建订单之间运行;如果适配器不支持预留,除非运营者设置 CHECKOUT_UNRESERVED=allow 并书面接受风险,否则该部署不会创建在线订单。缺少这条规则: 所有关于“防止超卖”的说法都只适用于同一时间恰好只有一个请求,这可不是这句话的意思。如果 createCheckout 之后失败,预留的库存会释放。
7. 判定结果必须附带证据。 每一行都会返回 stock_available 和 stock_verified,无论是否通过。缺少这条规则: 报告精确库存的工具需要令牌才能访问(这是后台数据),因此公开 Agent 无法诊断问题,只能盲目重试,还会编造原因告诉用户。如果你的权限边界不允许 Agent 使用诊断工具,那么负责写入的工具就必须提供诊断结果。
8. 任意一行无效,都应阻止整个购物车。 缺少这条规则: 用户收到的订单只包含“恰好通过检查的商品”,但那根本不是用户下单的购物车。
9. 读给客户的金额,就是客户要支付的金额。 税费、运费
和折扣由适配器处理,因此 CheckoutReceipt.total 可能与行项目小计不同——
如果不同,响应会分别说明两者的含义
(amount_to_pay、charges),而不是返回两个互相矛盾的数字。
没有这一点: 以 subtotal 作为最终金额的资金合同会默默假定价格已含税且免运费。
在欧盟或美国实现这套功能的人,要么在后端加上税费,导致总额与同一响应刚报告的
小计不一致;要么不加税,结果收款不足。
补货通知
10. 对外执行的操作需要经过服务器验证的业务前置条件。 只有商品确实缺货时才会发
邮件:确认库存为零,或者在无法确认库存数量时,由商品目录独立确认缺货。
没有这一点: 一个向调用方指定的地址发送邮件的公开工具,就是一门针对第三方的邮件大炮。
只要循环调用 tools/call,并传入 {email: [email protected], slug: <cualquiera>},就能从你的域名发出成千上万封措辞得体的邮件,消耗额度和发送信誉,
而收件人从未联系过这家商店。(顺带一提:这也避免了对从未缺货的商品误发“补货啦!”通知。)
11. 对发送行为去重,而不只是对订阅去重,并采用失败即关闭策略。 这是两个不同的问题:“这个邮箱是否已经订阅?”以及“这个邮箱是否已经收到过该商品的邮件, 且之后无人采取任何操作?”没有第二项: 第一项无法防范真正需要防范的情况,因为攻击者 永远不会确认——三次相同调用会发出三封邮件,而且严格来说都不算重复订阅。发送前会进行 这两项查询;只要其中任何一项失败,就不发送邮件:后端故障绝不能成为发送邮件的许可。
12. 配额按收件人的邮箱地址计算,且有效期取决于你的适配器。 每个邮箱每小时最多发送
三封确认邮件,并且独立于任何按调用方设置的限制进行评估。没有邮箱这一部分: 按字面
字符串计算不算配额,因为一个邮箱可以有无数种写法:victima@、victima+1@、
victima+2@、v.i.c.t.i.m.a@ 和 VICTIMA@ 都会到达同一个 Gmail 账户,
每个写法却都能获得独立的三封配额。没有持久性这一部分: 内存计数器会在冷启动时
重置、在多个实例之间分散,并在重新部署时清空,因此这个上限只存在于纸面上。实现
NotifyAdapter.countOptInEmails 后,配额就会在你的存储中统计;如果不实现,工具响应会
注明 quota_enforcement: "best_effort",而不会承诺一个无法保证的数字。
13. 自动邮件绝不会发送到调用方指定的地址。 在网页结账流程中,你会给客户和管理员发邮件。
在 MCP 工具中,customer_email 是由Agent填写的。没有这一点: 在写入工具中接入“订单确认”
功能,会再次打开另一工具刚刚关上的那门大炮。规则是:公开工具输入的地址只能收到双重选择加入确认邮件,
不能收到其他邮件;任何其他邮件都必须先有意向证明——而付款就是这种证明。(此模板不会给任何运营人员发邮件。
如果要发送订单通知,请通过你自己的适配器发送到你自己环境中的地址,绝不要发送到
draft.customer_email。)
14. 使用构造良好的令牌实现双重选择加入,其中密钥必须是真正的
密钥。 v1.<payload>.<hmac>,过期时间必须在已签名的 payload
内,采用恒定时间比较,签名密钥至少为 32 个字符,并在缺失或长度不足时
默认拒绝(工具返回“无法使用选择加入”而不是直接订阅)。*没有这些:*未签名的令牌
可以伪造;将过期时间存放在令牌旁边而不是令牌内部会导致过期时间被
忽略;对签名使用 === 会逐字节泄露签名;缺失的环境变量会变成敞开的大门;
而 STOCK_ALERT_SIGNING_SECRET=x 虽然通过了“非空”检查,但任何人都能
为任意地址计算出有效令牌并通过 POST 发送——无需任何人选择加入,就实现了
双重选择加入。对外,“缺少密钥”“格式错误”和“签名无效”共用同一条
消息;只有“已过期”会单独提示,因为这对用户有帮助,对攻击者则毫无用处。
15. 没有确认 URL,就不发送邮件。 *没有这一点:*这是整个流程中唯一一个
默认放行的环节,而其他环节都会默认拒绝。设置了签名密钥,却没有配置
站点 URL 时,工具仍会发送邮件,并附上指向 https://example.com 的
确认链接——这是运营者无法控制的域名——其中的查询字符串包含一个已签名的
令牌,令牌内还带有收件人的地址,并被发送给第三方。失效的链接已经够糟。
失效链接指向别人的域名,其中还带有你客户的地址,则更糟。
16. GET 负责呈现,POST 负责写入——页面要说明正在确认的内容。 确认页面在 GET 请求中不会写入任何内容;只有在请求正文中通过表单提交令牌的 POST 请求才会持久化数据。*没有这一点:*公司邮件网关(Safe Links、URL Defense、桌面 杀毒软件、客户端预取功能)会在邮件送达时对邮件中的所有 URL 发起 GET 请求。你向受害者的地址发送确认邮件,其所在公司的安全扫描器就会触发订阅: 这正是双重选择加入旨在阻止的他人代为选择加入,却从后门溜了进来。扫描器会跟踪链接, 但不会提交表单。这条规则也适用于任何通过邮件发送的链接触发的操作:确认、取消、批准、 退订。此外,页面会写明产品和具体变体,因为订阅一个产品系列和订阅该系列中的某一种色调 是不同的订阅;如果同意页面不说明这种区别,就称不上知情同意。写入操作具有幂等性,因此 双击也能成功,无需提交支持工单。
两个工具
17. 默认采用 dry 模式,每个工具都有 kill switch。 除非显式设为 live,否则 CHECKOUT_MODE 和
STOCK_ALERT_MODE 的值均为 dry;设为 off 则会将工具彻底从 tools/list 中移除。dry 模式下的调用会运行所有检查,并准确说明原本会执行什么操作——包括“此操作原本会被阻止,以及原因”。没有这项机制: 一个写入工具只要部署就会自行启用,因此根本无法进行演练:它的首次实际调用就是在生产环境中动用资金。只有精确的字符串 live 才会进入 live 模式,因此拼写错误会导致不执行任何操作。
18. 无法如实响应的工具不会被公布,读取工具也不例外。 tools/list 由端到端的能力门控:没有
getQuote 就不会有 get_quote;没有 OpsAdapter 就不会有后台工具,即使调用方已获授权也一样;没有 CheckoutAdapter、InventoryAdapter 和报价器,就不会有 create_checkout。无论出于何种原因,未公布的工具都会以相同方式返回 -32601。没有这项机制: 你就会分发存根。WooCommerce 适配器实现了三个无法响应的方法——listBrands 返回 [],getColorCard 返回 null,getQuote 则拒绝所有请求——只是为了满足接口要求;服务器还会向任何匿名 Agent 公布这三个工具:始终为空的色卡列表、永远只会返回“未找到”的搜索,以及总是失败的报价。让 Agent 两次走进死胡同,比没有这个工具更糟。
19. 边界说明必须与实际边界一致。 错误会列出两个工具,并说明它们共享配额;工具描述也会说明这一点,包括部署无法识别调用方的情况。没有这项机制: 在两个工具之间切换的 Agent 会比说明的更早触及限制,进而认为计数器是按工具单独计算的,于是重试——速率限制反而制造了它本应阻止的流量。在 MCP 服务中,工具描述和错误消息就是 Agent 可见的 API;语义描述不准确,就会付出重试的代价。
20. 速率限制的键不能由调用方选择。 见下文。
关于这个 rate limiter(诚实版)
“按 IP 限流”有两个问题,第二个几乎从未被提及。
限流键。 人人都知道不能信任 x-forwarded-for 的第一个值。容易忽略的是:转发 header 是由代理写入的;如果前面没有代理——在 VPS 上运行 node server.js、直接运行 next start、未配置 proxy_set_header 的 nginx、使用公开端口的容器——那么整个 header,包括最后一跳,都是调用方写入的字符串。轮换 X-Forwarded-For: 203.0.113.1, .2, .3…,每个请求都会新建一个计数桶,限流器也就形同虚设。因此,除非你明确指定由边缘代理写入的 header,否则本服务不会信任任何转发 header:
TRUSTED_PROXY_HEADER=x-vercel-forwarded-for # Vercel
TRUSTED_PROXY_HEADER=cf-connecting-ip # Cloudflare
TRUSTED_PROXY_HEADER=x-storefront-client-ip # el proxy de WordPress incluido
指定一个 header,就等于声称两件事:你的边缘代理会覆盖它,而且没有其他流量能到达源站。如果源站可以通过公网访问,任何找到它的人都能伪造这个 header——先关闭源站的公网访问(部署保护、防火墙、mTLS)。如果没有声明任何 header,--http 服务会使用 TCP 对端地址,没人能伪造它;无服务器 Fetch handler 没有可供查询的 socket,因此无法识别调用方——而无法识别调用方的部署,每分钟会为整个进程设置 60 次写入的上限,而不是承诺一个它无法兑现的单调用方额度。(不是共享的 5 次/分钟:把所有人都塞进一个小计数桶,会让限流器变成针对你自己客户的拒绝服务攻击,比它原本要解决的问题还糟。)
存储。 内置计数器是进程内的一个 Map。在无服务器环境中,这意味着 N 个温热实例 = N 份独立额度;冷启动会重置计数器,重新部署会清空计数器。这只是防止 Agent 循环的摩擦机制,不是 WAF,也不是滥用防护;src/lib/ratelimit.ts 中也明确标注了这一点,而不是把它说成团队实际上并不具备的上限。rateLimited(key, max, windowMs) 是一个小型同步接口,调用方式只有一种,因此换成 Redis/KV/Durable Objects 很容易。在依赖这个计数器做任何事之前,先完成替换。
正因如此,真正重要的限制都作用于实际效果:可用性故障时拒绝服务、原子预留、无库存前置条件、发送时去重、双重 opt-in 和 kill switch。无论实例有多少,这些机制都能成立,因为它们由你的数据库而非计数器来执行。每个收件人的邮件额度介于两者之间:如果你的 NotifyAdapter 实现了 countOptInEmails,额度就是持久的;否则就是尽力而为——工具的响应会告诉你属于哪种情况,而不是让你猜。
写入工具配置
| 变量 | 默认值 | 作用 |
|---|---|---|
CHECKOUT_MODE | dry | create_checkout 的 off | dry | live |
STOCK_ALERT_MODE | dry | subscribe_stock_alert 的 off | dry | live |
CHECKOUT_UNRESERVED | refuse | 在 live 模式下,当库存适配器无法预留商品数量时的处理方式:refuse(不创建订单)或 allow(接受并发调用可能导致超卖;每张收据都会说明) |
STOCK_ALERT_SIGNING_SECRET | (无值) | opt-in 链接的 HMAC 密钥,至少 32 个字符。若缺失或长度不足,则无法生成链接,工具会拒绝请求。openssl rand -hex 32 |
STOCK_ALERT_CONFIRM_URL | ${NEXT_PUBLIC_SITE_URL}/api/stock-alert/confirm | 确认页面所在位置。两者都未设置时,live 模式不会发送任何内容 |
STOCK_ALERT_PAGE_LOCALE | en | 该页面的语言(en | es),这是客户端能看到的唯一页面 |
TRUSTED_PROXY_HEADER | (无值) | 边缘节点写入客户端 IP 的 header 名称。未设置 ⇒ 忽略转发 headers |
只有当活动适配器提供相应能力时,工具才会公布,因此上述配置无法让适配器无法支持的工具重新启用。
60 秒内测试防护措施
玩具目录经过设置,可展示所有状态。
chromaflow-classic-set-12 有 4 件;CF-G09 是目录中没有库存记录的变体;fieldbook-sketch-a5 标记为 in_stock,但数量为零(典型的超卖情形);而 fieldbook-sketch-a4 是常规情况:一款有 60 件库存的商品,可通过 slug 或 SKU FB-A4 查询。
npx storefront-mcp --http 8787 &
# Consolidación + fail-closed: dos líneas de 3 para un producto con 4 unidades.
# Por línea cada una "cabe". Juntas no.
curl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_checkout","arguments":{
"items":[{"slug":"chromaflow-classic-set-12","qty":3},{"slug":"chromaflow-classic-set-12","qty":3}]}}}'
# → bloqueado, qty_requested 6, merged_from_input_lines 2, stock_available 4
# El mismo producto escrito de dos formas, una sola pila de unidades:
# 50 por slug + 50 por SKU contra 60. Por separado cada línea "cabe";
# lo que se verifica es el pozo.
curl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{
"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_checkout","arguments":{
"items":[{"slug":"fieldbook-sketch-a4","qty":50},{"sku":"FB-A4","qty":50}]}}}'
# → bloqueado: "only 60 unit(s) available and 100 requested across the lines
# that share this stock", más shares_stock_with en cada línea
# Stock no verificable bloquea igual que cero:
# "items":[{"sku":"CF-G09","qty":1}] → stock_verified:false, blocked
# Las cantidades se rechazan, no se corrigen:
# "items":[{"slug":"fieldbook-sketch-a5","qty":0}] → items_invalid_qty, qty_received 0
作为 Web 端点运行(2 分钟)
要通过你自己的域名提供 MCP 服务(可部署的 Next.js 路由):
git clone <este repo> && cd storefront-mcp
npm install
npm run dev
完成:memory 适配器(默认)通过 examples/toy-catalog.json 提供玩具目录,这是一个虚构商店。英文部分中的相同 curl 命令可以直接使用。
如果进行部署,请将 TRUSTED_PROXY_HEADER 设置为平台写入的 header(在 Vercel 上为 x-vercel-forwarded-for,在 Cloudflare 后方为 cf-connecting-ip),否则写入配额会变成整个实例共享的单一上限——请参阅关于这个速率限制器:实话实说。
编写自己的适配器
协议层从不直接接触数据:它会调用 src/lib/adapter.ts 中的接口。两个方法是必需的;其他所有内容都是能力,由这些能力决定有哪些工具:
CatalogAdapter(必需)——searchProducts和getProduct,这就是全部必需内容。默认公开。同一接口中的可选方法:getPromotions→get_promotions;getQuote→get_quote(也是create_checkout的前置条件);listVariantCharts+getVariantChart→ 两个规格表工具,要么一起发布,要么都不发布。“规格表”是指库存按规格管理的产品系列:颜色、尺码、颗粒、烘焙程度、容量。如果你的目录没有这个维度,就不要实现它们;无需提供存根。OpsAdapter——启用 5 个受令牌保护的工具。InventoryAdapter——getAvailability(refs)按商品行返回{units, verified, reason, pool}。这是“当前有多少件”的数据来源,并且必须与读取工具使用的数据来源相同。可选但至关重要的方法:reserve(req)/release(id);缺少它们时,实时结账会被拒绝(防护措施 6)。CheckoutAdapter——createCheckout(draft)。只有同时具备InventoryAdapter和getQuote时才会发布。NotifyAdapter——isSubscribed/sendOptInEmail/confirmSubscription,以及可选的countOptInEmails,它可以将每位收件人的名额转换为实际上限。
只实现两个必需方法的适配器也能完美运行:其余工具只是不发布。这是受支持的配置,而不是降级配置。
复制 src/lib/adapters/memory.ts 作为参考,将其连接到你的数据库或 API,然后在 src/lib/adapters/index.ts 中注册。契约的诚实规则:如果无法验证库存,就返回 units: null / stock: null(绝不要编造数字,也不要回退为 0);每一行库存可用性都要返回一个 pool——即该商品行所消耗的库存池标识;同一产品的 slug 和 SKU 必须对应同一个库存池,否则以两种方式请求的同一产品会针对同一库存重复验证;将 reserve 实现为一项原子操作(UPDATE … WHERE (on_hand − reserved) >= :qty),要么全部成功,要么全部失败,并实现 release 以便回滚;所有库存响应都必须来自同一数据源,确保写入路径不会基于与展示给客户的数据不同的内容进行验证;扣除其他地方已承诺的数量(未关闭的预留、其他渠道);遇到错误时,让 isSubscribed 抛出异常,而不是返回 false,以便调用方能够采取失败关闭策略;为每次外部调用设置超时;get_quote 绝不能收费——它只负责报价,并回显收到的 slug/sku,同时按请求数量报价,否则就拒绝该商品行,因为结账会比较两者,并在不一致时拒绝整个购物车。
项目中包含一个适用于 WooCommerce(Store API)的骨架,它只实现自身能够响应的内容——search_products、get_product、get_promotions——不提供任何存根。TODO 代码块描述了 WooCommerce 安装中其余每项契约所需的内容,包括无法由他人替你决定的两个问题:按规格管理库存的语义,以及如何预留商品数量。
发现
discovery/ 中的模板:/.well-known/mcp.json(供机器读取的描述文件;替换 {{DOMAIN}},并从 public/.well-known/ 提供该文件),以及一段包含 Agent 策略的 /llms.txt 代码片段。此外,GET /api/mcp 会返回 JSON 描述文件。只列出你的部署实际公布的工具。
对于官方 MCP 注册表,server.json 是清单。先发布到 npm:注册表会验证已发布 tarball 中的 mcpName,因此注册一个 npm 上尚不存在的版本,会留下一个指向空处的条目。.github/workflows/publish-mcp-registry.yml 工作流会在运行前验证这一点。
MCP 技能
服务器也会通过协议提供自己的使用说明,作为一项 skill(SEP-2640,扩展 io.modelcontextprotocol/skills)。实现了该扩展的宿主会通过 skills/list 发现它,使用 resources/read 读取 skill://storefront-mcp/SKILL.md,并验证条目附带的 sha256 摘要。未实现该扩展的宿主会将同一文件视为普通资源;一切照常运行。
该扩展依据协议的 2026-07-28 修订版编写,因此服务器现在会响应 server/discover 并协商该修订版(无状态,每个请求都在 _meta 中携带版本,每个结果都包含 resultType,列表中包含 ttlMs/cacheScope)。旧客户端仍使用 2025-06-18 或 2025-11-25 调用 initialize,不会察觉任何变化(initialize 从不响应 2026-07-28:该修订版没有握手)。此外,server/discover 的响应还会附带指向该 skill 的 instructions,这样不支持此扩展的宿主(目前的 Claude Code)也能在模型决定时将其作为资源读取。
支持 2026-07-28 也意味着拒绝该修订版规定必须拒绝的请求:_meta 中包含此服务器不支持的协议版本(返回 -32022,并附上支持的版本列表)、现代请求缺少 clientCapabilities(返回 -32602),以及在 HTTP 中缺少或与请求正文矛盾的镜像请求头 MCP-Protocol-Version、Mcp-Method 和 Mcp-Name(返回 -32020,HTTP 400)。现代请求中出现未知方法时,会返回 404。未附带 _meta 的请求(较早的修订版)仍按原先方式处理。
SKILL.md 会根据部署的实际配置,针对每个调用方生成:其中会准确列出 tools/list 向该调用方公布的工具,说明每个写入工具当前处于哪种模式(dry 或 live),以及 Agent 权限的边界(未明确要求购买时不得结账;未提供接收地址时不得发送通知);对于操作员会话,还会说明可用的后台工具;匿名调用方的 skill 绝不会提及这些工具。因此,配置发生变化时,其摘要也会随之变化,这正是预期行为:固定了摘要的宿主会在那时重新读取该文件。
使用支持此扩展的客户端进行测试(mcpc 0.7 或更高版本可像任何 MCP 宿主一样,通过配置文件连接):
cat > mcp.json <<'EOF'
{ "mcpServers": { "tienda": { "command": "npx", "args": ["-y", "storefront-mcp"] } } }
EOF
npx -y @apify/mcpc connect mcp.json:tienda @tienda
npx -y @apify/mcpc @tienda skills-list
npx -y @apify/mcpc @tienda skills-get storefront-mcp
npm test 会编译 CLI,并对两种传输方式进行端到端测试:声明的能力、skills/list 和 skills/get 保持一致;frontmatter 会解析为 YAML,且字段与条目相同(这是宿主必须执行的验证);摘要和大小与提供的字节相符;skill 准确列出 tools/list 公布的工具(双向验证);匿名调用方的 skill 绝不提及后台工具。测试还会覆盖上述 2026-07-28 修订版的拒绝情形,以及非 JSON-RPC 请求正文(null、数字、空批次);这些请求会以 -32600 响应,而不会被执行。
在你的 WordPress 域名下运行 MCP
如果你的商店运行在 WordPress/WooCommerce 上,但 MCP 服务器部署在其他地方,wordpress-proxy/mcp-proxy.php 是一个 mu-plugin,可通过代理上游服务来提供 https://tutienda.com/api/mcp。它会以优先级 0 挂接到 init,原样转发 Authorization header,将客户端的真实 IP 转发到 X-Storefront-Client-IP(覆盖调用方发送的任何值),处理 CORS 预检请求,以描述符响应 GET,将 payload 限制为 256 KB;如果上游服务发生故障,则返回 JSON-RPC 错误,而不会返回 HTML 页面。安装时,将文件复制到 wp-content/mu-plugins/,并在 wp-config.php 中定义 STOREFRONT_MCP_UPSTREAM。
然后在上游服务中定义 TRUSTED_PROXY_HEADER=x-storefront-client-ip:如果不这样做,所有请求都会使用 WordPress 服务器的地址,因此整个商店的写入限额会变成每分钟 5 次调用;一个陷入循环的 Agent 就能让所有客户都无法结账。只有在无法绕过代理直接访问上游服务时,才信任这个 header;如果上游服务可公开访问,任何找到它的人都可以自行写入这个 header。
为什么不直接用 Shopify 的 MCP?
如果你使用 Shopify:Shopify 会为每家商店提供一个带有通用 search_catalog 的 MCP endpoint,而且运行良好。直接用它即可。这个模板面向该 endpoint 无法覆盖的场景:Shopify 以外的商店(WooCommerce、自有技术栈、headless),尤其是平台无法替你生成的工具。这里的完整示例是 get_variant_chart,它会返回某个产品系列的完整变体列表,以及每个变体的实时库存。任何商店都可以说“我们卖这些马克笔”;只有接入了自己的库存系统的商店才能说“W3 色号现在有货,R21 没货”。按变体提供这样的回答能促成销售,而通用工具无法做到。
还有第二个原因:你可以为之负责的写入工具。托管平台会替你决定其结账流程会检查什么。这里的拒绝规则都在你的代码仓库中,可以审查;保护第三方的规则则不可配置。
2.0 版本中的破坏性变更
get_color_card→get_variant_chart(参数brand→chart),list_brands→list_variant_charts。旧名称描述的是商店的业务领域,而非接口约定;payload 将colors重命名为variants,将hex重命名为swatch_hex(可选)。CatalogAdapter现在只要求实现searchProducts和getProduct。其他四个方法都是可选的,每个方法分别控制对应工具是否启用。- 新增了
AvailabilityRow.pool。不返回该字段的适配器仍可正常工作,但无法检测 slug/SKU 冲突(防护措施 3)。 - 实际运行
create_checkout时,必须提供InventoryAdapter.reserve,除非设置CHECKOUT_UNRESERVED=allow(防护措施 6)。 - 只有当
TRUSTED_PROXY_HEADER指定某个 header 时,才会采纳转发 headers(防护措施 20)。 STOCK_ALERT_SIGNING_SECRET必须至少有 32 个字符;现在,实际运行时的提醒还要求配置确认 URL。
许可证
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。