{
  "markdown": "# storefront-mcp\n\n**An MCP server template for e-commerce storefronts.** AI agents get your\ncatalog; only you get your back office.\n\n*(Español más abajo / Spanish below.)*\n\n---\n\n## Quickstart (30 seconds)\n\n```bash\nnpx storefront-mcp\n```\n\nThat starts an MCP server over **stdio** serving a demo catalog (the bundled\n`memory` adapter) with 8 public tools — 6 read tools plus the two write\ntools, which start in **dry mode**: they run every check and then create\nnothing. Plug it into Claude Desktop or\nClaude Code by adding this to your MCP config (`claude_desktop_config.json`,\nor `claude mcp add storefront -- npx storefront-mcp`):\n\n```json\n{\n  \"mcpServers\": {\n    \"storefront\": {\n      \"command\": \"npx\",\n      \"args\": [\"storefront-mcp\"]\n    }\n  }\n}\n```\n\nWant the 5 back-office tools too? On stdio there is no HTTP header, so the\ngate is the presence of `MCP_SECRET` in the server process env — whoever\nlaunches the process owns the machine it runs on:\n\n```json\n{\n  \"mcpServers\": {\n    \"storefront\": {\n      \"command\": \"npx\",\n      \"args\": [\"storefront-mcp\"],\n      \"env\": { \"MCP_SECRET\": \"anything-non-empty\" }\n    }\n  }\n}\n```\n\nPrefer curl? `npx storefront-mcp --http 8787` serves the same JSON-RPC\ncontract over plain HTTP on localhost, with the real\n`Authorization: Bearer <MCP_SECRET>` check (same behavior as the Next.js\nroute below), plus the opt-in confirmation page at\n`/api/stock-alert/confirm`:\n\n```bash\nnpx storefront-mcp --http 8787 &\ncurl -s http://127.0.0.1:8787/ -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'\n```\n\nPick the adapter with `CATALOG_ADAPTER` (`memory` by default,\n`woocommerce` for the Store API skeleton). To serve your own catalog, write\nan adapter (see below) — the CLI, the Next.js route and the registry entry\n(`server.json`) all reuse the same tool definitions and privilege boundary.\n\n## What is this\n\nA [Model Context Protocol](https://modelcontextprotocol.io) server, packaged\nas a Next.js App Router route, that exposes an online store to AI agents\n(Claude, custom GPTs, agent frameworks — anything that speaks MCP over\nStreamable HTTP). It defines **13 tools**, and announces the subset your\nadapter can actually answer:\n\n| Public read (no auth) | Public write (guarded) | Sensitive (Bearer token) |\n| --- | --- | --- |\n| `search_products` | `create_checkout` | `get_stock_bulk` |\n| `get_product` | `subscribe_stock_alert` | `get_top_products` |\n| `get_variant_chart` | | `get_recent_orders` |\n| `list_variant_charts` | | `get_order_status` |\n| `get_promotions` | | `get_sales_summary` |\n| `get_quote` | | |\n\nOnly `search_products` and `get_product` are always present. Everything else\nis a capability: implement the adapter method and the tool appears, skip it\nand the tool does not exist on your deployment — see\n[guard rail 18](#both-tools).\n\nThe write tools are public on purpose — an agent buying on a human's behalf is\nthe point — so their protection is behavioral, not a token. They start in dry\nmode. See [the guard rails](#the-second-design-what-a-write-tool-must-refuse).\n\nIt is extracted from a production storefront server, with everything\nstore-specific removed and replaced by a clean adapter interface.\n\n## Why\n\nAI agents are becoming a sales channel. When someone asks their assistant\n\"find me a warm gray alcohol marker in stock near me\", the stores that win\nare the ones the agent can actually *query*: structured search, real\navailability, a quote with a payment link. A public MCP endpoint is how your\nstore shows up in that conversation — on your own domain, with your own data,\nunder your own rules.\n\n## The core design: privilege separation\n\n**An agent may browse the shop window; it never sees the operation.**\n\nEvery tool is either *public* or *sensitive*, and the boundary is enforced\ntwice in the protocol layer (`src/lib/protocol.ts`, shared by the Next.js\nroute and the standalone CLI):\n\n1. **`tools/list`** — without a valid `Authorization: Bearer <MCP_SECRET>`\n   header, only the public tools are returned. Sensitive tools are not merely\n   locked; they are invisible.\n2. **`tools/call`** — a caller who guesses a sensitive tool's name anyway gets\n   JSON-RPC error **`-32001`** before any data code runs.\n\nThe check is **fail-closed**: if the `MCP_SECRET` env var is not set, the\nsensitive tools are blocked for everyone. There is no\n\"nothing-configured-so-everything-is-open\" mode. Token comparison is\nconstant-time.\n\nTransport nuance: over HTTP (the Next.js route and `--http` mode) the gate is\nthe Bearer header, because remote callers are untrusted. Over **stdio**\n(`npx storefront-mcp`) there is no header — the client and server share a\nmachine — so the gate is whether `MCP_SECRET` exists in the server process\nenv. Same boundary, enforced at the trust seam each transport actually has.\n\nThe same split exists at the data layer: the `CatalogAdapter` interface only\nknows public storefront data, and the optional `OpsAdapter` (orders, revenue,\nexact stock) is a separate contract you can simply not implement — in which\ncase the sensitive tools are not announced at all, to anyone. Ops\nimplementations must anonymize customer PII: line items carry name/qty/price,\nnever emails, addresses or phone numbers, even behind auth.\n\n## The second design: what a write tool must refuse\n\nA read tool that is wrong says something inaccurate. A write tool that is\nwrong sells stock you do not have, or points a mail cannon at a stranger — at\nmachine speed, in a retry loop, with nobody in the room.\n\nSo the interesting part of `create_checkout` and `subscribe_stock_alert` is not\nwhat they do. It is what they refuse to do, and **the refusals that protect a\nthird party are not configurable**. You can switch the effect off entirely\n(dry mode, kill switch); you cannot keep the effect and drop the check.\n\nEach guard rail below is followed by *what breaks without it*. That is the part\nworth copying — the tools themselves are a few hundred lines you could write in\nan afternoon.\n\n### Checkout\n\n**1. Availability is checked against the inventory source, not the catalog.**\n\"Published and purchasable\" and \"there are units\" are two different questions,\nand almost every e-commerce stack answers them in two different systems (CMS\nvs. ERP/POS). *Without it:* the tool resolves each line against the sellable-catalog\nindex, hands it to the pricing code — which only knows prices — and no\ninventory query happens anywhere on the path. An agent orders 50 units of\nsomething you have 2 of and gets a real order plus a payable link.\n\n**2. \"I don't know\" blocks exactly like \"there is none\".** Availability is\ntri-state: `{units: n, verified: true}`, `{units: 0, verified: true}`,\n`{units: null, verified: false}`. *Without it:* the result gets modeled as a\nnumber, so every failure degrades to either `0` (silently blocking real sales)\nor \"assume it's fine\" (selling air). The three real \"I don't know\" cases —\nvariant not mapped in the inventory system, no row in the stock snapshot,\nbackend down — are none of them zero. Before charging a human, unknown and\nunavailable are worth the same.\n\n**3. Lines are consolidated before any limit or stock check — by UNIT POOL,\nnot by spelling.** *Without the first half:* a per-line cap of 50 units is\ndecorative, because twenty lines of the same SKU at qty 50 is 1,000 units and\neach one \"fits\". *Without the second half* — and this is the version that\nsurvives a naive dedupe — `{slug: \"notebook-a4\", qty: 50}` and\n`{sku: \"NB-A4\", qty: 50}` are two different keys for **one** product with\n**one** pile of units. Each line is checked against the same 60 units, each\none passes, and the store sells 100. Only the inventory adapter can resolve\nthat identity, so `AvailabilityRow` carries a `pool` field and every aggregate\nlimit is measured per pool. When an adapter does not return one, the response\nsays so instead of pretending the two lines were proven distinct.\n\n**4. An invalid quantity is rejected, never repaired.**\n`Math.min(Math.max(Math.floor(Number(qty) || 1), 1), 50)` reads like input\nsanitizing. *Without it:* `{qty: 0}` — which from an agent means \"remove this\"\n— becomes one unit billed to a human, and negatives, `NaN` and fractions\nbecome invented sales. In a tool that takes money, sanitizing means rejecting\nand explaining; rewriting input into something plausible is fabricating intent.\n\n**5. Prices are never accepted from the caller, and the quote is reconciled\nagainst the request.** There is no price field in the input schema at all, and\nbefore an order is created the server checks that the catalog priced *the\nquantity that was asked for* and that `unit_price × qty == line_total`.\n*Without the first half:* your discount policy is whatever the caller types.\n*Without the second half:* the quantity travels from the cart and the money\ntravels from the quote, and nothing compares them — so a pricing source that\n\"helpfully\" clamps 40 units to 10 produces an order for **40 units charged as\n10**, with every other guard rail green. A quote is allowed to reject a line;\nit is not allowed to answer a different question than the one asked.\n\n**6. Units are HELD before the order exists — or live checkout refuses.** This\nis the guard rail that a stateless check cannot be. Points 1–3 all describe the\npast: they read a number. Ten concurrent calls each read \"4 units left\", each\npass every check, and each create an order — 40 sold against 4, no rule\nbroken. Only an atomic decrement at the inventory source can prevent that, so\n`InventoryAdapter.reserve()` runs between the checks and the order, and a\ndeployment whose adapter cannot reserve does not create live orders unless the\noperator sets `CHECKOUT_UNRESERVED=allow` and accepts the risk in writing.\n*Without it:* every claim about \"preventing overselling\" holds for exactly one\nrequest at a time, which is not what the phrase means. If `createCheckout`\nthen fails, the hold is released.\n\n**7. The refusal ships with its evidence.** Every line comes back with\n`stock_available` and `stock_verified`, success or failure. *Without it:* the\ntool that reports exact stock is token-gated (it is back-office data), so the\npublic agent cannot diagnose anything, retries blindly, and tells the human a\nmade-up reason. If your privilege boundary denies the agent the diagnostic\ntool, the write tool owes it the diagnosis.\n\n**8. One bad line blocks the whole cart.** *Without it:* the human receives an\norder for \"the items that happened to pass\", which is a cart nobody asked for.\n\n**9. The number you read to the customer is the number they will pay.** Tax,\nshipping and discounts belong to the adapter, so `CheckoutReceipt.total` may\ndiffer from the line subtotal — and when it does, the response says which is\nwhich (`amount_to_pay`, `charges`) instead of returning two contradictory\nfigures. *Without it:* a money contract that stops at `subtotal` silently\nassumes tax-inclusive pricing and free delivery. An implementer in the EU or\nthe US either adds tax in their backend, so the total no longer matches the\nsubtotal the same response just reported, or does not, and undercharges.\n\n### Back-in-stock alerts\n\n**10. An outward effect needs a server-side business precondition.** The email\nonly goes out if the product is really unavailable — verified zero, or the\ncatalog independently saying out of stock when units cannot be verified.\n*Without it:* a public tool that emails an address chosen by the caller is a\nmail cannon aimed at third parties. Loop `tools/call` with\n`{email: victim@company.com, slug: <anything>}` and thousands of perfectly\nlegitimate-looking messages leave your domain, burning credits and sender\nreputation, hitting someone who never contacted the store. (Bonus: it also\nkills the false \"it's back!\" alert about a product that never left.)\n\n**11. Dedupe on the SEND, not only on the subscription — failing closed.**\nTwo different questions: \"is this mailbox already subscribed?\" and \"did we\nalready mail this mailbox about this product and hear nothing back?\". *Without\nthe second one:* the first protects nobody against the case that matters,\nbecause an attacker never confirms — three identical calls send three emails\nand every one of them is, technically, not a duplicate subscription. Both\nlookups happen before the send, and if either FAILS nothing is sent: a backend\nthat is down must never be promoted into permission to emit.\n\n**12. The quota is keyed by the recipient's MAILBOX, and it is only as durable\nas your adapter.** Three confirmation emails per hour per mailbox, evaluated\nindependently of any caller limit. *Without the mailbox part:* keying on the\nliteral string is no quota at all, because one inbox has unlimited spellings —\n`victim@`, `victim+1@`, `victim+2@`, `v.i.c.t.i.m@`, `VICTIM@` all land in the\nsame Gmail account and each one gets its own fresh allowance of three.\n*Without the durability part:* the in-memory counter resets on cold start,\nsplits across instances and dies on redeploy, so the ceiling exists on paper\nonly. Implement `NotifyAdapter.countOptInEmails` and the limit is a real\nceiling counted in your storage; skip it and the tool's own response says\n`quota_enforcement: \"best_effort\"` rather than promising a number it cannot\nhold.\n\n**13. The automated email never goes to the address the caller chose.** In a\nweb checkout you mail the customer and the admin. In an MCP tool the\n`customer_email` was typed by an *agent*. *Without it:* wiring \"order\nconfirmation\" into the write tool re-opens the exact cannon the alert tool just\nclosed. Rule: an address arriving as tool input may receive one double-opt-in\nmessage and nothing else; any other mail needs prior proof of intent — which is\nwhat paying is. (Nothing in this template mails an operator. If you want order\nnotifications, send them from your own adapter to an address in your own env —\nnever to `draft.customer_email`.)\n\n**14. Double opt-in with a properly built token, including a key long enough\nto be one.** `v1.<payload>.<hmac>`, the expiry **inside** the signed payload,\ntiming-safe comparison, a 32-character minimum on the signing secret, and\nfail-closed when it is missing or too short (the tool answers \"opt-in\nunavailable\" instead of subscribing directly). *Without it:* unsigned tokens\nare forged; an expiry stored beside the token instead of inside it gets\nignored; `===` on a signature leaks it byte by byte; a missing env var becomes\nan open door; and `STOCK_ALERT_SIGNING_SECRET=x` passes a \"non-empty\" check\nwhile letting anyone compute a valid token for any address and POST it\nthemselves — double opt-in with nobody opting in. Outwardly, \"no secret\",\n\"malformed\" and \"bad signature\" share one message; only \"expired\" is\ndistinguished, because it is actionable for the human and useless to an\nattacker.\n\n**15. No confirmation URL, no email.** *Without it:* the one fail-*open* in a\nflow where everything else fails closed. With the signing secret set and no\nsite URL configured, the tool used to send anyway, with a confirmation link\npointing at `https://example.com` — a domain the operator does not own —\ncarrying a signed token with the recipient's own address inside it, in a query\nstring, to a third party. A dead link is bad. A dead link on somebody else's\ndomain with your customer's address in it is worse.\n\n**16. GET renders, POST writes — and the page says what is being confirmed.**\nThe confirmation page performs zero writes on GET; only a POST with the token\nin a form body persists anything. *Without it:* corporate mail gateways (Safe\nLinks, URL Defense, desktop AV, client prefetch) fetch every link in every\nmessage at delivery. Send a confirmation to a victim's address and their own\nemployer's security scanner activates the subscription — the third-party\nopt-in you built double opt-in to prevent, re-entered through the back door.\nScanners follow links; they do not submit forms. This generalizes to anything\ntriggered from an emailed link: confirm, cancel, approve, unsubscribe. The\npage also names the product and the specific variant, because subscribing to a\nproduct line and subscribing to one shade of it are different subscriptions\nand a consent screen that omits the difference is not consent. The write is\nidempotent, so a double-click is a success rather than a support ticket.\n\n### Both tools\n\n**17. Dry by default, with a per-tool kill switch.** `CHECKOUT_MODE` and\n`STOCK_ALERT_MODE` are `dry` unless explicitly set to `live`; `off` removes the\ntool from `tools/list` entirely. A dry call runs every check and then answers\nwith exactly what it would have done — including \"this would have been BLOCKED,\nhere is why\". *Without it:* a write tool that arms itself by being deployed has\nno rehearsal — its first real invocation is in production, against money. Only\nthe exact string `live` reaches live, so a typo fails towards doing nothing.\n\n**18. A tool that cannot be honest is not announced — and that covers the read\ntools.** `tools/list` is capability-gated end to end: no `getQuote`, no\n`get_quote`; no `OpsAdapter`, no back-office tools even for an authorized\ncaller; no `CheckoutAdapter` *and* `InventoryAdapter` *and* pricing, no\n`create_checkout`. Anything not announced answers `-32601`, the same way, for\nevery reason. *Without it:* you ship stubs. The WooCommerce adapter used to\nimplement three methods it could not answer — `listBrands` returning `[]`,\n`getColorCard` returning `null`, `getQuote` rejecting every line — purely to\nsatisfy the interface, and the server announced all three to every anonymous\nagent: a chart list that is always empty, a lookup that always says \"not\nfound\", a quote that always fails. A dead end an agent walks into twice is\nworse than a tool that is not there.\n\n**19. The limit description matches the limit.** The error names both tools and\nsays the quota is shared; the tool descriptions say the same, *including the\ncase where the deployment cannot identify callers*. *Without it:* an agent\nalternating the two tools hits a wall earlier than announced, concludes the\ncounter is per-tool, and retries — the rate limit generating the traffic it\nexists to stop. For an MCP server, tool descriptions and error strings are the\nagent-facing API, and a mis-described limit is paid in retries.\n\n**20. The rate-limit key cannot be chosen by the caller.** See below.\n\n### About that rate limiter (the honest version)\n\nTwo things are usually wrong with \"rate limit by IP\", and the second one is\nrarely mentioned.\n\n**The key.** Everyone knows not to trust the *first* `x-forwarded-for` entry.\nThe part that gets missed: a forwarding header is written by a **proxy**, and\nwith no proxy in front of you — `node server.js` on a VPS, a bare `next\nstart`, nginx without `proxy_set_header`, a container with a public port —\nthe whole header, last hop included, is a string the caller typed. Rotating\n`X-Forwarded-For: 203.0.113.1, .2, .3…` then mints a fresh bucket per request\nand the limiter does nothing. So this server trusts **no** forwarding header\nunless you name the one your edge writes:\n\n```bash\nTRUSTED_PROXY_HEADER=x-vercel-forwarded-for   # Vercel\nTRUSTED_PROXY_HEADER=cf-connecting-ip         # Cloudflare\nTRUSTED_PROXY_HEADER=x-storefront-client-ip   # the bundled WordPress proxy\n```\n\nNaming a header asserts two things: your edge *overwrites* it, and nothing\nelse can reach the origin. If the origin is publicly reachable, that header is\nforgeable by whoever finds it — lock the origin down first (deployment\nprotection, a firewall, mTLS). With nothing declared, the `--http` server uses\nthe TCP peer address, which nobody can forge; a serverless Fetch handler has\nno socket to ask, so callers are **unattributed** — and an unattributed\ndeployment gets a process-wide ceiling of 60 writes/minute rather than a\nper-caller promise it cannot keep. (Not a shared 5/min: collapsing every\ncaller into one small bucket turns the rate limiter into a denial of service\nagainst your own customers, which is a worse bug than the one it fixes.)\n\n**The store.** The bundled counter is a `Map` in the memory of one process. On\nserverless that means **N warm instances = N independent quotas**, a cold start\nresets it, and a redeploy erases it. It is friction against an agent loop, not\na WAF and not an abuse control, and it is labelled that way in\n`src/lib/ratelimit.ts` rather than presented as a ceiling the team does not\nactually have. `rateLimited(key, max, windowMs)` is a small synchronous port\nwith a single call-site shape, so swapping in Redis/KV/Durable Objects is\nmechanical. Do that before you rely on the counter for anything.\n\nWhich is exactly why the limits that matter are attached to the **effect**:\nfail-closed availability, the atomic reservation, the out-of-stock\nprecondition, dedupe at the point of the send, double opt-in, and the kill\nswitch. Those hold no matter how many instances are running, because they are\nenforced by your data, not by a counter. The per-recipient email quota sits in\nbetween: durable when your `NotifyAdapter` implements `countOptInEmails`,\nbest-effort otherwise — and the tool response says which one you have rather\nthan leaving you to guess.\n\n## Configuration for the write tools\n\n| Env var | Default | What it does |\n| --- | --- | --- |\n| `CHECKOUT_MODE` | `dry` | `off` \\| `dry` \\| `live` for `create_checkout` |\n| `STOCK_ALERT_MODE` | `dry` | `off` \\| `dry` \\| `live` for `subscribe_stock_alert` |\n| `CHECKOUT_UNRESERVED` | `refuse` | In live mode, what to do when the inventory adapter cannot **hold** units: `refuse` (no order) or `allow` (accepts that concurrent calls can oversell; every receipt says so) |\n| `STOCK_ALERT_SIGNING_SECRET` | *(unset)* | HMAC key for opt-in links, **min 32 chars**. Missing or too short ⇒ no link can be issued and the tool refuses. `openssl rand -hex 32` |\n| `STOCK_ALERT_CONFIRM_URL` | `${NEXT_PUBLIC_SITE_URL}/api/stock-alert/confirm` | Where the confirmation page lives. With neither set, live mode sends **nothing** |\n| `STOCK_ALERT_PAGE_LOCALE` | `en` | Language of that page (`en` \\| `es`) — the one screen a customer sees |\n| `TRUSTED_PROXY_HEADER` | *(unset)* | Name of the header your edge writes with the client IP. Unset ⇒ forwarding headers are ignored |\n\nA tool is announced only when the active adapter provides the capability, so\nnone of the above resurrects a tool the adapter cannot honor.\n\n## Trying the guard rails in 60 seconds\n\nThe bundled toy catalog is arranged so every state shows up.\n`chromaflow-classic-set-12` has **4** units; `CF-G09` is a catalogued variant\nwith **no inventory row**; `fieldbook-sketch-a5` is published as `in_stock`\nwith **zero** units (the oversell shape itself); and `fieldbook-sketch-a4` is\nthe ordinary case — one product with **60** units addressable both by its slug\nand by its SKU `FB-A4`.\n\n```bash\nnpx storefront-mcp --http 8787 &\n\n# Consolidation + fail-closed: two lines of 3 for a product with 4 units.\n# Per line each one \"fits\". Together they do not.\ncurl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{\n  \"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"create_checkout\",\"arguments\":{\n    \"items\":[{\"slug\":\"chromaflow-classic-set-12\",\"qty\":3},{\"slug\":\"chromaflow-classic-set-12\",\"qty\":3}]}}}'\n# → blocked, qty_requested 6, merged_from_input_lines 2, stock_available 4\n\n# Same product, two spellings, one pile of units: 50 by slug + 50 by SKU\n# against 60. Both lines \"fit\" on their own; the pool is what gets checked.\ncurl -s http://127.0.0.1:8787/ -H 'content-type: application/json' -d '{\n  \"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"create_checkout\",\"arguments\":{\n    \"items\":[{\"slug\":\"fieldbook-sketch-a4\",\"qty\":50},{\"sku\":\"FB-A4\",\"qty\":50}]}}}'\n# → blocked: \"only 60 unit(s) available and 100 requested across the lines that\n#   share this stock\", plus shares_stock_with on every line\n\n# Unverifiable stock blocks like zero:\n#   \"items\":[{\"sku\":\"CF-G09\",\"qty\":1}]   → stock_verified:false, blocked\n\n# Quantities are rejected, not repaired:\n#   \"items\":[{\"slug\":\"fieldbook-sketch-a5\",\"qty\":0}] → items_invalid_qty, qty_received 0\n```\n\n## Quickstart as a web endpoint (2 minutes)\n\nTo serve MCP from your own domain (the deployable Next.js route):\n\n```bash\ngit clone <this repo> && cd storefront-mcp\nnpm install\nnpm run dev\n```\n\nThat's it — the default `memory` adapter serves the toy catalog in\n`examples/toy-catalog.json` (a fictional store, \"Demo Art Supply\"). Try it:\n\n```bash\n# descriptor\ncurl http://localhost:3000/api/mcp\n\n# list tools (public only — no token sent)\ncurl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'\n\n# search\ncurl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_products\",\"arguments\":{\"query\":\"leather dye\"}}}'\n\n# a sensitive tool without a token → -32001\ncurl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"get_sales_summary\",\"arguments\":{}}}'\n\n# now with the token\nexport MCP_SECRET=$(openssl rand -hex 32)   # also set it in .env.local and restart\ncurl -s http://localhost:3000/api/mcp -H 'content-type: application/json' \\\n  -H \"authorization: Bearer $MCP_SECRET\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"get_sales_summary\",\"arguments\":{}}}'\n```\n\nTo connect it to Claude Code: `claude mcp add --transport http my-store\nhttp://localhost:3000/api/mcp`.\n\nDeploying it? Set `TRUSTED_PROXY_HEADER` to the header your platform writes\n(`x-vercel-forwarded-for` on Vercel, `cf-connecting-ip` behind Cloudflare), or\nthe write quota becomes a single ceiling for the whole instance — see\n[the rate limiter](#about-that-rate-limiter-the-honest-version).\n\n## Writing your own adapter\n\nThe protocol layer never touches data directly. It calls the interfaces\ndefined in `src/lib/adapter.ts`. **Two methods are required. Everything else\nis a capability, and capabilities decide which tools exist**:\n\n- **`CatalogAdapter`** (required) — `searchProducts` and `getProduct`, and\n  that is the whole obligation. Public by definition: assume every byte it\n  returns is world-readable. Optional on the same interface:\n  - `getPromotions` → `get_promotions`\n  - `getQuote` → `get_quote` (and it is a precondition for `create_checkout`)\n  - `listVariantCharts` + `getVariantChart` → the two chart tools, announced\n    together or not at all. A \"variant chart\" is one product line whose stock\n    lives per variant — color, size, grit, roast, capacity. If your catalog\n    has no such axis, do not implement them; there is nothing to stub.\n- **`OpsAdapter`** — `getStockBulk`, `getTopProducts`, `getRecentOrders`,\n  `getOrderStatus`, `getSalesSummary`. Enables the token-gated tools.\n- **`InventoryAdapter`** — `getAvailability(refs)` returning\n  `{units, verified, reason, pool}` per line. This is the \"how many units right\n  now\" source, and it must be the same one your read tools use. Optional but\n  load-bearing: `reserve(req)` / `release(id)`, without which live checkout\n  refuses (guard rail 6).\n- **`CheckoutAdapter`** — `createCheckout(draft)`. Announced only alongside an\n  `InventoryAdapter` and a `getQuote`.\n- **`NotifyAdapter`** — `isSubscribed` / `sendOptInEmail` /\n  `confirmSubscription`, plus the optional `countOptInEmails` that turns the\n  per-recipient quota into a real ceiling.\n\nAn adapter that implements only the two required methods keeps working exactly\nas expected: everything else is simply never announced. That is a supported\nconfiguration, not a degraded one.\n\nSteps:\n\n1. Copy `src/lib/adapters/memory.ts` (the reference implementation) to a new\n   file and point it at your database / API / ERP.\n2. Register it in `src/lib/adapters/index.ts` and select it with the\n   `CATALOG_ADAPTER` env var.\n3. Keep the contract's honesty rules:\n   - return `units: null` / `stock: null` when you could not verify\n     availability — **never invent a number, and never fall back to 0**;\n   - return a `pool` on every availability row: the identity of the pile of\n     units that line draws from, with a product's slug and its SKU resolving\n     to the **same** pool. Without it, one product ordered two ways is checked\n     twice against the same stock;\n   - implement `reserve` as ONE atomic operation (`UPDATE … WHERE (on_hand −\n     reserved) >= :qty`), all-or-nothing, and `release` for the rollback;\n   - serve every stock answer from one source, so the write path cannot\n     validate against something different from what the customer was shown;\n   - subtract what is already committed elsewhere (open holds, other channels)\n     — \"on the shelf\" is not \"sellable to this customer\";\n   - let `isSubscribed` throw on failure instead of returning `false`; the\n     caller fails closed and sends nothing;\n   - set a per-call timeout so a hung backend degrades into a note instead of\n     a hung agent;\n   - keep `get_quote` charge-free, make `getQuote` echo the `slug`/`sku` it was\n     given on each priced line, and make it price **the quantity it was\n     asked for or reject the line** — the checkout path compares the two and\n     refuses the cart when they disagree.\n\nA **WooCommerce skeleton** (`src/lib/adapters/woocommerce.ts`) is included,\nbuilt on the public Store API. It implements exactly three tools' worth of\ncatalog (`search_products`, `get_product`, `get_promotions`) and stubs\nnothing; the TODO blocks describe what each remaining contract needs from a\nWooCommerce install, including the two decisions — per-variant stock semantics\nand how to hold units — that nobody can make for you.\n\n## Discovery: getting found\n\nAgents can only call what they can find. Two artifacts, templates in\n`discovery/`:\n\n- **`/.well-known/mcp.json`** — machine-readable descriptor\n  (`discovery/well-known-mcp.json`; replace `{{DOMAIN}}`, serve from\n  `public/.well-known/mcp.json`). List only public tools in it, and only the\n  ones your deployment actually announces.\n- **`/llms.txt`** — human/LLM-readable site guide\n  (`discovery/llms-txt-snippet.md`); includes an agent policy section: re-check\n  stock before closing a sale, quotes never charge, `stock: null` means\n  unknown, quote `amount_to_pay` rather than `subtotal`.\n\nAdditionally, `GET /api/mcp` returns a JSON descriptor so anyone poking the\nendpoint understands what it is.\n\nFor the official [MCP Registry](https://registry.modelcontextprotocol.io),\n`server.json` at the repo root is the manifest: it points at the\n`storefront-mcp` npm package with stdio transport, so registry clients can\nrun it via `npx`. **Publish to npm first** — the registry validates the\n`mcpName` inside the published tarball, so registering a version npm does not\nhave yet creates an entry pointing at nothing. The workflow in\n`.github/workflows/publish-mcp-registry.yml` checks that before it runs.\n\n## Serving MCP from your WordPress domain\n\nIf your storefront runs WordPress/WooCommerce but the MCP server deploys\nelsewhere (e.g. Vercel), `wordpress-proxy/mcp-proxy.php` is a **mu-plugin**\nthat serves `https://yourshop.com/api/mcp` by proxying to the upstream:\n\n- hooks `init` at priority 0 (answers before WordPress routing),\n- forwards POST bodies and the `Authorization` header untouched (the upstream\n  enforces the privilege split),\n- forwards the real client address in `X-Storefront-Client-IP`, overwriting\n  anything the caller sent,\n- handles CORS preflight, answers GET with a readable descriptor,\n- caps payloads at 256 KB,\n- on upstream failure returns a JSON-RPC error object — never an HTML error\n  page, because the client is a program.\n\nInstall: drop the file in `wp-content/mu-plugins/` and define\n`STOREFRONT_MCP_UPSTREAM` in `wp-config.php`. Then set\n`TRUSTED_PROXY_HEADER=x-storefront-client-ip` **on the upstream** — without\nit, every request arrives wearing the WordPress server's address and the\nwrite quota becomes 5 calls per minute for the entire store, so one looping\nagent locks every customer out of checkout. Only trust that header if the\nupstream cannot be reached except through the proxy; if it is publicly\nreachable, anyone who finds it can write the header themselves.\n\n## Why not just Shopify's MCP?\n\nIf you are on Shopify: Shopify already gives every store a hosted MCP endpoint\nwith a generic `search_catalog`-style tool, and it is good. Use it. This\ntemplate is for the cases it does not cover:\n\n- **You are not on Shopify** — WooCommerce, custom stack, headless, an ERP\n  from 2009 that somehow still works.\n- **Your differentiator is a tool the platform will never generate.** The\n  worked example here is `get_variant_chart`: the full variant chart of a\n  product line with *live stock per variant*. Any store can say \"we sell these\n  markers\"; only the store that wired its own inventory can say \"shade W3 is in\n  stock right now, shade R21 is not\". That per-variant answer closes sales, and\n  it needs domain knowledge no generic platform tool has.\n- **You want the privilege-separated back office** — the same endpoint, with a\n  token, answering \"what were my top sellers this month?\" to *you* while\n  showing agents only the shop window.\n- **You want write tools you can actually defend.** A hosted platform decides\n  for you what its checkout tool checks. Here the refusals are in your repo,\n  reviewable, and the ones that protect a third party are not configurable.\n\n## Repository layout\n\n```\nsrc/lib/protocol.ts           protocol core (JSON-RPC, auth boundary, capability gating, dispatch)\nsrc/app/api/mcp/route.ts      Next.js transport (Streamable HTTP + Bearer)\nsrc/cli/cli.ts                standalone transport: `npx storefront-mcp` (stdio, or --http + confirm page)\nsrc/lib/tools.ts              tool definitions, built from what the adapter can answer\nsrc/lib/adapter.ts            the five adapter contracts + types\nsrc/lib/commerce.ts           create_checkout / subscribe_stock_alert — the guard rails\nsrc/lib/availability.ts       tri-state availability, pool identity, blocksWrite()\nsrc/lib/cart.ts               consolidate first, then measure limits; reject bad quantities\nsrc/lib/money.ts              currency-aware rounding (not everything has two decimals)\nsrc/lib/email.ts              address vs mailbox: canonical keys for quota and dedupe\nsrc/lib/optin.ts              signed double opt-in tokens (exp inside payload, fail-closed)\nsrc/lib/optin-page.ts         the confirmation page: GET renders, POST writes (framework-free)\nsrc/lib/ratelimit.ts          rateLimited(key,max,window) + what an in-memory limiter is NOT\nsrc/lib/client-ip.ts          caller identity: no forwarding header is trusted unless declared\nsrc/lib/write-mode.ts         off | dry | live per tool, + the unreserved-checkout policy\nsrc/lib/errors.ts             ToolCallError (quota / capability refusals)\nsrc/app/api/stock-alert/confirm/route.ts  Next.js transport for the confirmation page\nsrc/lib/adapters/memory.ts    reference adapter (all five contracts, one stock source, real holds)\nsrc/lib/adapters/woocommerce.ts  Store API skeleton — implements only what it can answer\nsrc/lib/adapters/index.ts     adapter registry (env CATALOG_ADAPTER)\nexamples/toy-catalog.json     the demo data, incl. a separate `inventory` section\nserver.json                   MCP Registry manifest (registry.modelcontextprotocol.io)\ntsconfig.build.json           compiles lib + cli to dist/ for the npm bin\ndiscovery/                    /.well-known/mcp.json + llms.txt templates\nwordpress-proxy/mcp-proxy.php mu-plugin to serve MCP under your WP domain\n```\n\n## Breaking changes in 2.0\n\n- `get_color_card` → **`get_variant_chart`** (argument `brand` → `chart`) and\n  `list_brands` → **`list_variant_charts`**. The old names described one\n  store's domain, not the contract; the payload renames `colors` → `variants`\n  and `hex` → optional `swatch_hex`.\n- `CatalogAdapter` now requires only `searchProducts` and `getProduct`. The\n  other four methods are optional, and each one gates its tool.\n- `AvailabilityRow.pool` is new. Adapters that do not return it still work,\n  but cannot catch slug/SKU collisions (guard rail 3).\n- Live `create_checkout` requires `InventoryAdapter.reserve` unless\n  `CHECKOUT_UNRESERVED=allow` (guard rail 6).\n- Forwarding headers are ignored unless `TRUSTED_PROXY_HEADER` names one\n  (guard rail 20).\n- `STOCK_ALERT_SIGNING_SECRET` must be at least 32 characters, and live alerts\n  now require a configured confirmation URL.\n\n## License\n\nApache-2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).\n\n---\n\n# storefront-mcp (Español)\n\n**Plantilla de servidor MCP para tiendas online.** Los agentes de IA ven tu\ncatálogo; tu operación la ves solo tú.\n\n## Partir en 30 segundos\n\n```bash\nnpx storefront-mcp\n```\n\nEso levanta un servidor MCP por **stdio** con un catálogo de demostración (el\nadaptador `memory`) y 8 tools públicas: 6 de lectura más las dos de\nescritura, que arrancan en **modo dry** (corren todas las verificaciones y no\ncrean nada). Para conectarlo a Claude Desktop\no Claude Code, agrega esto a tu configuración MCP (o ejecuta\n`claude mcp add storefront -- npx storefront-mcp`):\n\n```json\n{\n  \"mcpServers\": {\n    \"storefront\": {\n      \"command\": \"npx\",\n      \"args\": [\"storefront-mcp\"]\n    }\n  }\n}\n```\n\n¿Quieres también las 5 tools de trastienda? En stdio no existe el header\nHTTP, así que la llave es la **presencia** de `MCP_SECRET` en el entorno del\nproceso del servidor (quien lanza el proceso es dueño de la máquina donde\ncorre):\n\n```json\n{\n  \"mcpServers\": {\n    \"storefront\": {\n      \"command\": \"npx\",\n      \"args\": [\"storefront-mcp\"],\n      \"env\": { \"MCP_SECRET\": \"cualquier-valor-no-vacio\" }\n    }\n  }\n}\n```\n\n¿Prefieres curl? `npx storefront-mcp --http 8787` sirve el mismo contrato\nJSON-RPC por HTTP en localhost, con el chequeo real de\n`Authorization: Bearer <MCP_SECRET>` (mismo comportamiento que la ruta de\nNext.js) y además la página de confirmación de opt-in en\n`/api/stock-alert/confirm`. El adaptador se elige con `CATALOG_ADAPTER`\n(`memory` por defecto, `woocommerce` para el esqueleto de la Store API).\n\n## Qué es\n\nUn servidor [MCP](https://modelcontextprotocol.io) empaquetado como ruta de\nNext.js (App Router) que expone una tienda online a agentes de IA (Claude,\nGPTs personalizados, frameworks de agentes — cualquier cliente MCP sobre\nStreamable HTTP). Define **13 tools** y anuncia el subconjunto que tu\nadaptador puede responder de verdad:\n\n- 6 públicas de lectura: `search_products`, `get_product`,\n  `get_variant_chart`, `list_variant_charts`, `get_promotions`, `get_quote`.\n- 2 públicas de escritura, con guard rails: `create_checkout` y\n  `subscribe_stock_alert`.\n- 5 sensibles protegidas por token: `get_stock_bulk`, `get_top_products`,\n  `get_recent_orders`, `get_order_status`, `get_sales_summary`.\n\nSolo `search_products` y `get_product` están siempre. Todo lo demás es una\ncapacidad: si implementas el método del adaptador la tool aparece; si no, esa\ntool no existe en tu despliegue (ver guard rail 18).\n\nLas tools de escritura son públicas a propósito — que un agente compre por\nencargo de una persona es justamente el punto — así que su protección está en\nel comportamiento, no en un token. Arrancan en modo dry. Ver\n[los guard rails](#el-segundo-diseño-qué-tiene-que-rechazar-una-tool-de-escritura).\n\nEstá extraído de un servidor de tienda en producción, con todo lo específico\nde esa tienda removido y reemplazado por una interfaz de adaptadores.\n\n## Por qué\n\nLos agentes de IA se están convirtiendo en un canal de venta. Cuando alguien\nle pide a su asistente \"búscame un marcador gris cálido con stock\", ganan las\ntiendas que el agente puede *consultar* de verdad: búsqueda estructurada,\ndisponibilidad real, una cotización con link de pago. Un endpoint MCP público\nes la forma de aparecer en esa conversación — en tu propio dominio, con tus\ndatos y tus reglas.\n\n## El diseño central: separación de privilegios\n\n**Un agente puede mirar la vitrina; nunca ve la operación.**\n\nCada tool es *pública* o *sensible*, y el límite se aplica dos veces en la\ncapa de protocolo:\n\n1. **`tools/list`** — sin un `Authorization: Bearer <MCP_SECRET>` válido,\n   solo se devuelven las tools públicas. Las sensibles no están bloqueadas:\n   son invisibles.\n2. **`tools/call`** — quien adivine el nombre de una tool sensible recibe el\n   error JSON-RPC **`-32001`** antes de que corra cualquier código de datos.\n\nEl chequeo es **fail-closed**: si `MCP_SECRET` no está definido en el\nentorno, las tools sensibles quedan bloqueadas para todos. No existe el modo\n\"no configuré nada, entonces todo queda abierto\". La comparación del token es\nde tiempo constante.\n\nMatiz por transporte: sobre HTTP (la ruta de Next.js y el modo `--http`) la\nllave es el header Bearer, porque quien llama desde afuera no es de\nconfianza. Sobre **stdio** (`npx storefront-mcp`) no hay header — cliente y\nservidor comparten la máquina — así que la llave es que `MCP_SECRET` exista\nen el entorno del proceso. Es el mismo límite, aplicado en la costura de\nconfianza que cada transporte realmente tiene.\n\nLa misma separación existe en la capa de datos: `CatalogAdapter` solo conoce\ndatos públicos de vitrina, y el `OpsAdapter` (órdenes, ventas, stock exacto)\nes un contrato aparte que puedes simplemente no implementar; en ese caso las\ntools sensibles no se anuncian a nadie. Las implementaciones de ops deben\nanonimizar la información de clientes: los ítems llevan nombre/cantidad/precio,\nnunca correos, direcciones ni teléfonos, incluso detrás de la autenticación.\n\n## El segundo diseño: qué tiene que rechazar una tool de escritura\n\nUna tool de lectura equivocada dice algo inexacto. Una tool de escritura\nequivocada vende stock que no existe, o apunta un cañón de correo contra un\ntercero — a velocidad de máquina, en un loop de reintentos, sin nadie mirando.\n\nPor eso lo interesante de `create_checkout` y `subscribe_stock_alert` no es lo\nque hacen, sino lo que se niegan a hacer, y **los rechazos que protegen a un\ntercero no son configurables**. Se puede apagar el efecto completo (modo dry,\nkill switch); no se puede conservar el efecto y quitar la verificación.\n\nCada guard rail viene con *qué se rompe sin él*. Esa es la parte que vale la\npena copiar: las tools en sí son unos cientos de líneas que cualquiera escribe\nen una tarde.\n\n### Checkout\n\n**1. La disponibilidad se verifica contra el inventario, no contra el\ncatálogo.** \"Publicado y comprable\" y \"hay unidades\" son dos preguntas\ndistintas, y casi todo stack de e-commerce las responde en dos sistemas\ndistintos (CMS vs. ERP/POS). *Sin esto:* la tool resuelve cada línea contra el\níndice de catálogo vendible, se la pasa al cotizador — que solo sabe de precios\n— y en todo el camino no hay una sola consulta de inventario. Un agente pide 50\nunidades de algo de lo que hay 2 y recibe un pedido real con link de pago\ncobrable.\n\n**2. \"No sé\" bloquea igual que \"no hay\".** La disponibilidad es tri-estado:\n`{units: n, verified: true}`, `{units: 0, verified: true}`,\n`{units: null, verified: false}`. *Sin esto:* el resultado se modela como\nnúmero y todo error degrada a `0` (bloqueando ventas legítimas en silencio) o a\n\"asumamos que hay\" (vendiendo aire). Los tres casos reales de \"no sé\" — variante\nsin mapear en inventario, producto sin fila en el snapshot, backend caído — no\nson cero. Antes de cobrarle a alguien, desconocido y agotado valen lo mismo.\n\n**3. Las líneas se consolidan antes de cualquier tope o chequeo de stock, y se\nconsolidan por POZO DE UNIDADES, no por cómo se escribieron.** *Sin la primera\nmitad:* un tope de 50 unidades por línea es decorativo, porque veinte líneas\ndel mismo SKU con qty 50 son 1.000 unidades y cada una \"cabe\". *Sin la segunda\nmitad* — y esta es la versión que sobrevive a un dedupe ingenuo —\n`{slug: \"cuaderno-a4\", qty: 50}` y `{sku: \"CU-A4\", qty: 50}` son dos claves\ndistintas para **un** producto con **una** pila de unidades. Cada línea se\ncompara contra las mismas 60 unidades, las dos pasan, y la tienda vende 100.\nSolo el adaptador de inventario puede resolver esa identidad, así que\n`AvailabilityRow` lleva un campo `pool` y todo límite agregable se mide por\npozo. Si el adaptador no lo devuelve, la respuesta lo dice en vez de fingir\nque quedó demostrado que son dos productos distintos.\n\n**4. Una cantidad inválida se rechaza, no se corrige.**\n`Math.min(Math.max(Math.floor(Number(qty) || 1), 1), 50)` parece saneo de\nentrada. *Sin esto:* `{qty: 0}` — que de un agente significa \"saca esto\" — se\nconvierte en una unidad cobrada a una persona, y los negativos, `NaN` y\nfraccionarios se convierten en ventas inventadas. En una tool que cobra,\nsanear es rechazar y explicar; reescribir la entrada a algo plausible es\nfabricar intención.\n\n**5. Los precios nunca vienen del llamador, y la cotización se reconcilia\ncontra lo pedido.** El schema de entrada no tiene campo de precio, y antes de\ncrear un pedido el servidor verifica que el catálogo cotizó *la cantidad que se\npidió* y que `unit_price × qty == line_total`. *Sin la primera mitad:* tu\npolítica de descuentos es lo que escriba quien llame. *Sin la segunda:* la\ncantidad viaja desde el carro y el dinero viaja desde la cotización, y nada las\ncompara — así que un cotizador que \"ayuda\" recortando 40 unidades a 10 produce\nun pedido de **40 unidades cobrado como 10**, con todos los demás guard rails\nen verde. Una cotización puede rechazar una línea; lo que no puede es responder\nuna pregunta distinta de la que se le hizo.\n\n**6. Las unidades se RESERVAN antes de que exista el pedido, o el checkout en\nvivo rechaza.** Este es el guard rail que una verificación sin estado no puede\nser. Los puntos 1 a 3 describen el pasado: leen un número. Diez llamadas\nconcurrentes leen \"quedan 4\", las diez pasan todas las verificaciones y las\ndiez crean un pedido: 40 vendidas contra 4, sin romper ninguna regla. Solo un\ndecremento atómico en la fuente de inventario lo impide, así que\n`InventoryAdapter.reserve()` corre entre las verificaciones y el pedido, y un\ndespliegue cuyo adaptador no sabe reservar no crea pedidos en vivo salvo que\nel operador ponga `CHECKOUT_UNRESERVED=allow` y acepte el riesgo por escrito.\n*Sin esto:* toda afirmación sobre \"evitar la sobreventa\" vale para exactamente\nun request a la vez, que no es lo que significa la frase. Si `createCheckout`\nfalla después, la reserva se libera.\n\n**7. El veredicto viaja con su evidencia.** Cada línea vuelve con\n`stock_available` y `stock_verified`, tanto si pasa como si no. *Sin esto:* la\ntool que reporta stock exacto está detrás de token (es dato de trastienda), así\nque el agente público no puede diagnosticar nada, reintenta a ciegas y le\ninventa un motivo a la persona. Si tu frontera de privilegios le niega al agente\nla tool de diagnóstico, la tool de escritura le debe el diagnóstico resuelto.\n\n**8. Una línea mala bloquea el carro completo.** *Sin esto:* la persona recibe\nun pedido con \"los ítems que casualmente pasaron\", que es un carro que nadie\npidió.\n\n**9. El número que le lees al cliente es el que va a pagar.** Impuestos, envío\ny descuentos son del adaptador, así que `CheckoutReceipt.total` puede diferir\ndel subtotal de líneas — y cuando difiere, la respuesta dice cuál es cuál\n(`amount_to_pay`, `charges`) en vez de devolver dos cifras contradictorias.\n*Sin esto:* un contrato de dinero que termina en `subtotal` asume en silencio\nprecios con impuesto incluido y envío gratis. Quien implemente esto en la UE o\nen EE.UU. o suma el impuesto en su backend, y entonces el total deja de\ncoincidir con el subtotal que esa misma respuesta acaba de reportar, o no lo\nsuma y cobra de menos.\n\n### Avisos de reposición\n\n**10. Un efecto hacia afuera necesita una precondición de negocio verificada en\nel servidor.** El correo sale solo si el producto está realmente sin stock:\ncero verificado, o el catálogo diciéndolo de forma independiente cuando las\nunidades no se pueden verificar. *Sin esto:* una tool pública que manda correo\na una dirección elegida por quien llama es un cañón de correo contra terceros.\nBasta hacer loop de `tools/call` con `{email: victima@empresa.com, slug:\n<cualquiera>}` para que salgan miles de mensajes impecables desde tu dominio,\ngastando créditos y reputación de envío, contra alguien que nunca habló con la\ntienda. (De yapa: también elimina el falso \"¡volvió el stock!\" sobre un producto\nque nunca faltó.)\n\n**11. Dedupe sobre el ENVÍO, no solo sobre la suscripción, y fail-closed.**\nSon dos preguntas distintas: \"¿esta casilla ya está suscrita?\" y \"¿ya le\nmandamos correo a esta casilla por este producto y nadie hizo nada?\". *Sin la\nsegunda:* la primera no protege del caso que importa, porque un atacante nunca\nconfirma — tres llamadas idénticas mandan tres correos y ninguna es,\ntécnicamente, una suscripción duplicada. Las dos consultas ocurren antes del\nenvío y, si cualquiera falla, no se manda nada: un backend caído nunca puede\nascender a permiso para emitir.\n\n**12. El cupo se keyea por la CASILLA del destinatario, y dura lo que dure tu\nadaptador.** Tres correos de confirmación por hora por casilla, evaluado aparte\nde cualquier límite por llamador. *Sin la parte de la casilla:* keyear el\nstring literal no es ningún cupo, porque una casilla tiene infinitas\nescrituras: `victima@`, `victima+1@`, `victima+2@`, `v.i.c.t.i.m.a@` y\n`VICTIMA@` llegan todas a la misma cuenta de Gmail y cada una estrena su propio\ncupo de tres. *Sin la parte de la durabilidad:* el contador en memoria se\nreinicia en un cold start, se reparte entre instancias y se borra en un\nredeploy, así que el techo existe solo en el papel. Implementa\n`NotifyAdapter.countOptInEmails` y el límite pasa a contarse en tu\nalmacenamiento; si no lo haces, la respuesta de la tool dice\n`quota_enforcement: \"best_effort\"` en vez de prometer un número que no puede\nsostener.\n\n**13. El correo automático nunca va a la dirección que eligió el llamador.** En\nun checkout web le escribes al cliente y al admin. En una tool MCP el\n`customer_email` lo escribió un *agente*. *Sin esto:* cablear \"confirmación de\npedido\" en la tool de escritura reabre exactamente el cañón que la otra tool\nacaba de cerrar. Regla: una dirección que llega como input de una tool pública\npuede recibir un mensaje de doble opt-in y nada más; cualquier otro correo\nnecesita prueba previa de intención — y pagar es esa prueba. (Esta plantilla no\nle manda correo a ningún operador. Si quieres avisos de pedido, mándalos desde\ntu propio adaptador a una dirección de tu propio entorno, nunca a\n`draft.customer_email`.)\n\n**14. Doble opt-in con un token bien construido, incluida una llave que sea\nllave.** `v1.<payload>.<hmac>`, la expiración **dentro** del payload firmado,\ncomparación de tiempo constante, un mínimo de 32 caracteres para el secreto de\nfirma, y fail-closed cuando falta o es más corto (la tool responde \"opt-in no\ndisponible\" en vez de suscribir directo). *Sin esto:* un token sin firma se\nfalsifica; una expiración guardada al lado del token en vez de adentro se\nignora; un `===` sobre la firma la filtra byte a byte; una variable de entorno\nfaltante se vuelve una puerta abierta; y `STOCK_ALERT_SIGNING_SECRET=x` pasa un\nchequeo de \"no vacío\" mientras cualquiera calcula un token válido para\ncualquier dirección y lo envía por POST — doble opt-in sin que nadie opte.\nHacia afuera, \"sin secreto\", \"mal formado\" y \"firma inválida\" comparten un\nmismo mensaje; solo \"vencido\" se distingue, porque es accionable para la\npersona e inútil para un atacante.\n\n**15. Sin URL de confirmación no hay correo.** *Sin esto:* el único fail-*open*\nen un flujo donde todo lo demás falla cerrado. Con el secreto de firma puesto y\nsin URL de sitio configurada, la tool mandaba igual, con un link de\nconfirmación a `https://example.com` — un dominio que el operador no controla —\nllevando un token firmado con la dirección del destinatario adentro, en la query\nstring, hacia un tercero. Un link muerto es malo. Un link muerto en el dominio\nde otro con la dirección de tu cliente adentro es peor.\n\n**16. El GET renderiza, el POST escribe — y la página dice qué se está\nconfirmando.** La página de confirmación no escribe nada en GET; solo un POST\ncon el token en el cuerpo del formulario persiste algo. *Sin esto:* los\ngateways de correo corporativos (Safe Links, URL Defense, antivirus de\nescritorio, prefetch del cliente) hacen GET a todas las URLs del mensaje al\nentregarlo. Mandas una confirmación a la dirección de una víctima y el escáner\nde seguridad de su propia empresa activa la suscripción: el opt-in ajeno que el\ndoble opt-in venía a impedir, entrando por la puerta de atrás. Los escáneres\nsiguen links; no envían formularios. La regla se generaliza a cualquier acción\ndisparada desde un link enviado por correo: confirmar, cancelar, aprobar, dar\nde baja. La página además nombra el producto y la variante específica, porque\nsuscribirse a una línea de productos y suscribirse a un solo tono de esa línea\nson suscripciones distintas, y una pantalla de consentimiento que omite la\ndiferencia no es consentimiento. La escritura es idempotente, así que un doble\nclick es un éxito y no un ticket de soporte.\n\n### Las dos tools\n\n**17. Dry por defecto, con kill switch por tool.** `CHECKOUT_MODE` y\n`STOCK_ALERT_MODE` valen `dry` salvo que se pongan explícitamente en `live`;\n`off` saca la tool de `tools/list` por completo. Una llamada en dry corre todas\nlas verificaciones y responde exactamente qué habría hecho — incluido \"esto\nhabría quedado BLOQUEADO y por qué\". *Sin esto:* una tool de escritura que se\narma sola por el hecho de estar desplegada no tiene ensayo posible: su primera\ninvocación real es en producción, contra dinero. Solo el string exacto `live`\nllega a live, así que un error de tipeo falla hacia no hacer nada.\n\n**18. Una tool que no puede ser honesta no se anuncia, y eso incluye las de\nlectura.** `tools/list` está gateado por capacidad de punta a punta: sin\n`getQuote` no hay `get_quote`; sin `OpsAdapter` no hay tools de trastienda ni\npara un llamador autorizado; sin `CheckoutAdapter` *y* `InventoryAdapter` *y*\ncotizador no hay `create_checkout`. Lo que no se anuncia responde `-32601`, de\nla misma forma, por cualquier motivo. *Sin esto:* despachas stubs. El adaptador\nde WooCommerce implementaba tres métodos que no podía responder — `listBrands`\ndevolviendo `[]`, `getColorCard` devolviendo `null`, `getQuote` rechazando todo\n— solo para satisfacer la interfaz, y el servidor anunciaba los tres a\ncualquier agente anónimo: una lista de cartas siempre vacía, una búsqueda que\nsiempre dice \"no encontrado\", una cotización que siempre falla. Un callejón sin\nsalida donde el agente entra dos veces es peor que una tool que no está.\n\n**19. La descripción del límite coincide con el límite.** El error nombra las\ndos tools y dice que el cupo es compartido; las descripciones dicen lo mismo,\n*incluido el caso en que el despliegue no puede identificar a quien llama*.\n*Sin esto:* un agente que alterna las dos tools choca antes de lo anunciado,\nconcluye que el contador es por tool y reintenta — el rate limit generando el\ntráfico que debía frenar. En un servidor MCP las descripciones y los mensajes\nde error son la API que ve el agente, y una semántica mal descrita se paga en\nreintentos.\n\n**20. La llave del rate limit no la elige quien llama.** Ver abajo.\n\n### Sobre ese rate limiter (la versión honesta)\n\nHay dos cosas mal en \"limitar por IP\", y la segunda casi nunca se menciona.\n\n**La llave.** Todo el mundo sabe que no hay que confiar en el *primer* valor de\n`x-forwarded-for`. Lo que se pasa por alto: un header de forwarding lo escribe\nun **proxy**, y si no hay proxy adelante — `node server.js` en un VPS, un\n`next start` pelado, nginx sin `proxy_set_header`, un contenedor con puerto\npúblico — el header entero, último salto incluido, es un string que escribió\nquien llama. Rotar `X-Forwarded-For: 203.0.113.1, .2, .3…` estrena un bucket\npor request y el limitador no hace nada. Por eso este servidor no confía en\n**ningún** header de forwarding salvo que nombres el que escribe tu edge:\n\n```bash\nTRUSTED_PROXY_HEADER=x-vercel-forwarded-for   # Vercel\nTRUSTED_PROXY_HEADER=cf-connecting-ip         # Cloudflare\nTRUSTED_PROXY_HEADER=x-storefront-client-ip   # el proxy de WordPress incluido\n```\n\nNombrar un header afirma dos cosas: que tu edge lo *sobrescribe* y que nada más\nllega al origen. Si el origen es alcanzable públicamente, ese header lo puede\nfalsificar cualquiera que lo encuentre — cierra el origen primero (protección\nde despliegue, firewall, mTLS). Sin nada declarado, el servidor `--http` usa la\ndirección del peer TCP, que nadie puede falsificar; un handler serverless tipo\nFetch no tiene socket que consultar, así que quien llama queda **sin atribuir**\n— y un despliegue sin atribución recibe un techo de 60 escrituras por minuto\npara todo el proceso, en vez de una promesa por llamador que no puede cumplir.\n(No un 5/min compartido: meter a todos en un bucket chico convierte el rate\nlimiter en una denegación de servicio contra tus propios clientes, que es peor\nque el problema que venía a resolver.)\n\n**El almacenamiento.** El contador incluido es un `Map` en la memoria de un\nproceso. En serverless eso significa **N instancias tibias = N cupos\nindependientes**, un cold start lo resetea y un redeploy lo borra. Es fricción\ncontra un loop de agente, no un WAF ni un control de abuso, y así está rotulado\nen `src/lib/ratelimit.ts` en vez de presentarse como un techo que el equipo en\nrealidad no tiene. `rateLimited(key, max, windowMs)` es un puerto síncrono chico\ncon una sola forma de llamada, así que cambiarlo por Redis/KV/Durable Objects es\nmecánico. Hazlo antes de confiar en el contador para algo.\n\nPor eso mismo los límites que importan están puestos en el **efecto**:\nfail-closed de disponibilidad, la reserva atómica, precondición de sin stock,\ndedupe en el punto del envío, doble opt-in y kill switch. Esos se sostienen sin\nimportar cuántas instancias haya, porque los aplica tu base de datos y no un\ncontador. El cupo de correo por destinatario queda en el medio: durable si tu\n`NotifyAdapter` implementa `countOptInEmails`, best-effort si no — y la\nrespuesta de la tool dice cuál de los dos tienes en vez de dejarte adivinar.\n\n## Configuración de las tools de escritura\n\n| Variable | Default | Qué hace |\n| --- | --- | --- |\n| `CHECKOUT_MODE` | `dry` | `off` \\| `dry` \\| `live` para `create_checkout` |\n| `STOCK_ALERT_MODE` | `dry` | `off` \\| `dry` \\| `live` para `subscribe_stock_alert` |\n| `CHECKOUT_UNRESERVED` | `refuse` | En modo live, qué hacer cuando el adaptador de inventario no puede **reservar** unidades: `refuse` (no se crea el pedido) o `allow` (acepta que llamadas concurrentes pueden sobrevender; cada recibo lo dice) |\n| `STOCK_ALERT_SIGNING_SECRET` | *(sin valor)* | Llave HMAC de los links de opt-in, **mínimo 32 caracteres**. Si falta o es corta, no se puede emitir link y la tool rechaza. `openssl rand -hex 32` |\n| `STOCK_ALERT_CONFIRM_URL` | `${NEXT_PUBLIC_SITE_URL}/api/stock-alert/confirm` | Dónde vive la página de confirmación. Sin ninguna de las dos, el modo live **no manda nada** |\n| `STOCK_ALERT_PAGE_LOCALE` | `en` | Idioma de esa página (`en` \\| `es`), la única pantalla que ve un cliente |\n| `TRUSTED_PROXY_HEADER` | *(sin valor)* | Nombre del header que tu edge escribe con la IP del cliente. Sin valor ⇒ los headers de forwarding se ignoran |\n\nUna tool se anuncia solo si el adaptador activo provee la capacidad, así que\nnada de lo anterior resucita una tool que el adaptador no puede cumplir.\n\n## Probar los guard rails en 60 segundos\n\nEl catálogo de juguete está armado para que aparezcan todos los estados.\n`chromaflow-classic-set-12` tiene **4** unidades; `CF-G09` es una variante del\ncatálogo **sin fila de inventario**; `fieldbook-sketch-a5` está publicado como\n`in_stock` con **cero** unidades (la forma exacta de una sobreventa); y\n`fieldbook-sketch-a4` es el caso corriente: un producto con **60** unidades\ndireccionable tanto por su slug como por su SKU `FB-A4`.\n\n```bash\nnpx storefront-mcp --",
  "bytes": 60000,
  "sha": "9aaaf7af47a648065816b7885f3f38d2770b9291f8bed8e11b3ca8d1b7feea44",
  "repo_slug": "maarmapa/storefront-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_maarmapa_storefront_mcp_d6163826/readme"
}