Server data from the Official MCP Registry
E-commerce storefront over MCP: public catalog tools plus token-gated back-office tools.
About
E-commerce storefront over MCP: public catalog tools plus token-gated back-office tools.
Security Report
A well-designed e-commerce MCP server with strong privilege separation, proper authentication enforcement, and clean code architecture. The security model is sound: public tools are unauthenticated, sensitive tools require Bearer token validation with constant-time comparison, and fail-closed behavior when secrets are unset. Minor code quality observations noted but do not materially impact security posture. Supply chain analysis found 8 known vulnerabilities in dependencies (0 critical, 5 high severity). Package verification found 1 issue.
6 files analyzed · 13 issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
What You'll Need
Set these up before or after installing:
Environment variable: CATALOG_ADAPTER
Environment variable: MCP_SECRET
Environment variable: MCP_SERVER_NAME
How to Install
Add this to your MCP configuration file:
{
"mcpServers": {
"io-github-maarmapa-storefront-mcp": {
"env": {
"MCP_SECRET": "your-mcp-secret-here",
"CATALOG_ADAPTER": "your-catalog-adapter-here",
"MCP_SERVER_NAME": "your-mcp-server-name-here"
},
"args": [
"-y",
"storefront-mcp"
],
"command": "npx"
}
}
}Documentation
View on GitHubFrom the project's GitHub README.
storefront-mcp
An MCP server template for e-commerce storefronts. AI agents get your catalog; only you get your back office.
(Español más abajo / Spanish below.)
Quickstart (30 seconds)
npx storefront-mcp
That starts an MCP server over stdio serving a demo catalog (the bundled
memory adapter) with 8 public tools — 6 read tools plus the two write
tools, which start in dry mode: they run every check and then create
nothing. Plug it into Claude Desktop or
Claude Code by adding this to your MCP config (claude_desktop_config.json,
or claude mcp add storefront -- npx storefront-mcp):
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"]
}
}
}
Want the 5 back-office tools too? On stdio there is no HTTP header, so the
gate is the presence of MCP_SECRET in the server process env — whoever
launches the process owns the machine it runs on:
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"],
"env": { "MCP_SECRET": "anything-non-empty" }
}
}
}
Prefer curl? npx storefront-mcp --http 8787 serves the same JSON-RPC
contract over plain HTTP on localhost, with the real
Authorization: Bearer <MCP_SECRET> check (same behavior as the Next.js
route below), plus the opt-in confirmation page at
/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"}'
Pick the adapter with CATALOG_ADAPTER (memory by default,
woocommerce for the Store API skeleton). To serve your own catalog, write
an adapter (see below) — the CLI, the Next.js route and the registry entry
(server.json) all reuse the same tool definitions and privilege boundary.
What is this
A Model Context Protocol server, packaged as a Next.js App Router route, that exposes an online store to AI agents (Claude, custom GPTs, agent frameworks — anything that speaks MCP over Streamable HTTP). It defines 13 tools, and announces the subset your adapter can actually answer:
| Public read (no auth) | Public write (guarded) | Sensitive (Bearer token) |
|---|---|---|
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 |
Only search_products and get_product are always present. Everything else
is a capability: implement the adapter method and the tool appears, skip it
and the tool does not exist on your deployment — see
guard rail 18.
The write tools are public on purpose — an agent buying on a human's behalf is the point — so their protection is behavioral, not a token. They start in dry mode. See the guard rails.
It is extracted from a production storefront server, with everything store-specific removed and replaced by a clean adapter interface.
Why
AI agents are becoming a sales channel. When someone asks their assistant "find me a warm gray alcohol marker in stock near me", the stores that win are the ones the agent can actually query: structured search, real availability, a quote with a payment link. A public MCP endpoint is how your store shows up in that conversation — on your own domain, with your own data, under your own rules.
The core design: privilege separation
An agent may browse the shop window; it never sees the operation.
Every tool is either public or sensitive, and the boundary is enforced
twice in the protocol layer (src/lib/protocol.ts, shared by the Next.js
route and the standalone CLI):
tools/list— without a validAuthorization: Bearer <MCP_SECRET>header, only the public tools are returned. Sensitive tools are not merely locked; they are invisible.tools/call— a caller who guesses a sensitive tool's name anyway gets JSON-RPC error-32001before any data code runs.
The check is fail-closed: if the MCP_SECRET env var is not set, the
sensitive tools are blocked for everyone. There is no
"nothing-configured-so-everything-is-open" mode. Token comparison is
constant-time.
Transport nuance: over HTTP (the Next.js route and --http mode) the gate is
the Bearer header, because remote callers are untrusted. Over stdio
(npx storefront-mcp) there is no header — the client and server share a
machine — so the gate is whether MCP_SECRET exists in the server process
env. Same boundary, enforced at the trust seam each transport actually has.
The same split exists at the data layer: the CatalogAdapter interface only
knows public storefront data, and the optional OpsAdapter (orders, revenue,
exact stock) is a separate contract you can simply not implement — in which
case the sensitive tools are not announced at all, to anyone. Ops
implementations must anonymize customer PII: line items carry name/qty/price,
never emails, addresses or phone numbers, even behind auth.
The second design: what a write tool must refuse
A read tool that is wrong says something inaccurate. A write tool that is wrong sells stock you do not have, or points a mail cannon at a stranger — at machine speed, in a retry loop, with nobody in the room.
So the interesting part of create_checkout and subscribe_stock_alert is not
what they do. It is what they refuse to do, and the refusals that protect a
third party are not configurable. You can switch the effect off entirely
(dry mode, kill switch); you cannot keep the effect and drop the check.
Each guard rail below is followed by what breaks without it. That is the part worth copying — the tools themselves are a few hundred lines you could write in an afternoon.
Checkout
1. Availability is checked against the inventory source, not the catalog. "Published and purchasable" and "there are units" are two different questions, and almost every e-commerce stack answers them in two different systems (CMS vs. ERP/POS). Without it: the tool resolves each line against the sellable-catalog index, hands it to the pricing code — which only knows prices — and no inventory query happens anywhere on the path. An agent orders 50 units of something you have 2 of and gets a real order plus a payable link.
2. "I don't know" blocks exactly like "there is none". Availability is
tri-state: {units: n, verified: true}, {units: 0, verified: true},
{units: null, verified: false}. Without it: the result gets modeled as a
number, so every failure degrades to either 0 (silently blocking real sales)
or "assume it's fine" (selling air). The three real "I don't know" cases —
variant not mapped in the inventory system, no row in the stock snapshot,
backend down — are none of them zero. Before charging a human, unknown and
unavailable are worth the same.
3. Lines are consolidated before any limit or stock check — by UNIT POOL,
not by spelling. Without the first half: a per-line cap of 50 units is
decorative, because twenty lines of the same SKU at qty 50 is 1,000 units and
each one "fits". Without the second half — and this is the version that
survives a naive dedupe — {slug: "notebook-a4", qty: 50} and
{sku: "NB-A4", qty: 50} are two different keys for one product with
one pile of units. Each line is checked against the same 60 units, each
one passes, and the store sells 100. Only the inventory adapter can resolve
that identity, so AvailabilityRow carries a pool field and every aggregate
limit is measured per pool. When an adapter does not return one, the response
says so instead of pretending the two lines were proven distinct.
4. An invalid quantity is rejected, never repaired.
Math.min(Math.max(Math.floor(Number(qty) || 1), 1), 50) reads like input
sanitizing. Without it: {qty: 0} — which from an agent means "remove this"
— becomes one unit billed to a human, and negatives, NaN and fractions
become invented sales. In a tool that takes money, sanitizing means rejecting
and explaining; rewriting input into something plausible is fabricating intent.
5. Prices are never accepted from the caller, and the quote is reconciled
against the request. There is no price field in the input schema at all, and
before an order is created the server checks that the catalog priced the
quantity that was asked for and that unit_price × qty == line_total.
Without the first half: your discount policy is whatever the caller types.
Without the second half: the quantity travels from the cart and the money
travels from the quote, and nothing compares them — so a pricing source that
"helpfully" clamps 40 units to 10 produces an order for 40 units charged as
10, with every other guard rail green. A quote is allowed to reject a line;
it is not allowed to answer a different question than the one asked.
6. Units are HELD before the order exists — or live checkout refuses. This
is the guard rail that a stateless check cannot be. Points 1–3 all describe the
past: they read a number. Ten concurrent calls each read "4 units left", each
pass every check, and each create an order — 40 sold against 4, no rule
broken. Only an atomic decrement at the inventory source can prevent that, so
InventoryAdapter.reserve() runs between the checks and the order, and a
deployment whose adapter cannot reserve does not create live orders unless the
operator sets CHECKOUT_UNRESERVED=allow and accepts the risk in writing.
Without it: every claim about "preventing overselling" holds for exactly one
request at a time, which is not what the phrase means. If createCheckout
then fails, the hold is released.
7. The refusal ships with its evidence. Every line comes back with
stock_available and stock_verified, success or failure. Without it: the
tool that reports exact stock is token-gated (it is back-office data), so the
public agent cannot diagnose anything, retries blindly, and tells the human a
made-up reason. If your privilege boundary denies the agent the diagnostic
tool, the write tool owes it the diagnosis.
8. One bad line blocks the whole cart. Without it: the human receives an order for "the items that happened to pass", which is a cart nobody asked for.
9. The number you read to the customer is the number they will pay. Tax,
shipping and discounts belong to the adapter, so CheckoutReceipt.total may
differ from the line subtotal — and when it does, the response says which is
which (amount_to_pay, charges) instead of returning two contradictory
figures. Without it: a money contract that stops at subtotal silently
assumes tax-inclusive pricing and free delivery. An implementer in the EU or
the US either adds tax in their backend, so the total no longer matches the
subtotal the same response just reported, or does not, and undercharges.
Back-in-stock alerts
10. An outward effect needs a server-side business precondition. The email
only goes out if the product is really unavailable — verified zero, or the
catalog independently saying out of stock when units cannot be verified.
Without it: a public tool that emails an address chosen by the caller is a
mail cannon aimed at third parties. Loop tools/call with
{email: victim@company.com, slug: <anything>} and thousands of perfectly
legitimate-looking messages leave your domain, burning credits and sender
reputation, hitting someone who never contacted the store. (Bonus: it also
kills the false "it's back!" alert about a product that never left.)
11. Dedupe on the SEND, not only on the subscription — failing closed. Two different questions: "is this mailbox already subscribed?" and "did we already mail this mailbox about this product and hear nothing back?". Without the second one: the first protects nobody against the case that matters, because an attacker never confirms — three identical calls send three emails and every one of them is, technically, not a duplicate subscription. Both lookups happen before the send, and if either FAILS nothing is sent: a backend that is down must never be promoted into permission to emit.
12. The quota is keyed by the recipient's MAILBOX, and it is only as durable
as your adapter. Three confirmation emails per hour per mailbox, evaluated
independently of any caller limit. Without the mailbox part: keying on the
literal string is no quota at all, because one inbox has unlimited spellings —
victim@, victim+1@, victim+2@, v.i.c.t.i.m@, VICTIM@ all land in the
same Gmail account and each one gets its own fresh allowance of three.
Without the durability part: the in-memory counter resets on cold start,
splits across instances and dies on redeploy, so the ceiling exists on paper
only. Implement NotifyAdapter.countOptInEmails and the limit is a real
ceiling counted in your storage; skip it and the tool's own response says
quota_enforcement: "best_effort" rather than promising a number it cannot
hold.
13. The automated email never goes to the address the caller chose. In a
web checkout you mail the customer and the admin. In an MCP tool the
customer_email was typed by an agent. Without it: wiring "order
confirmation" into the write tool re-opens the exact cannon the alert tool just
closed. Rule: an address arriving as tool input may receive one double-opt-in
message and nothing else; any other mail needs prior proof of intent — which is
what paying is. (Nothing in this template mails an operator. If you want order
notifications, send them from your own adapter to an address in your own env —
never to draft.customer_email.)
14. Double opt-in with a properly built token, including a key long enough
to be one. v1.<payload>.<hmac>, the expiry inside the signed payload,
timing-safe comparison, a 32-character minimum on the signing secret, and
fail-closed when it is missing or too short (the tool answers "opt-in
unavailable" instead of subscribing directly). Without it: unsigned tokens
are forged; an expiry stored beside the token instead of inside it gets
ignored; === on a signature leaks it byte by byte; a missing env var becomes
an open door; and STOCK_ALERT_SIGNING_SECRET=x passes a "non-empty" check
while letting anyone compute a valid token for any address and POST it
themselves — double opt-in with nobody opting in. Outwardly, "no secret",
"malformed" and "bad signature" share one message; only "expired" is
distinguished, because it is actionable for the human and useless to an
attacker.
15. No confirmation URL, no email. Without it: the one fail-open in a
flow where everything else fails closed. With the signing secret set and no
site URL configured, the tool used to send anyway, with a confirmation link
pointing at https://example.com — a domain the operator does not own —
carrying a signed token with the recipient's own address inside it, in a query
string, to a third party. A dead link is bad. A dead link on somebody else's
domain with your customer's address in it is worse.
16. GET renders, POST writes — and the page says what is being confirmed. The confirmation page performs zero writes on GET; only a POST with the token in a form body persists anything. Without it: corporate mail gateways (Safe Links, URL Defense, desktop AV, client prefetch) fetch every link in every message at delivery. Send a confirmation to a victim's address and their own employer's security scanner activates the subscription — the third-party opt-in you built double opt-in to prevent, re-entered through the back door. Scanners follow links; they do not submit forms. This generalizes to anything triggered from an emailed link: confirm, cancel, approve, unsubscribe. The page also names the product and the specific variant, because subscribing to a product line and subscribing to one shade of it are different subscriptions and a consent screen that omits the difference is not consent. The write is idempotent, so a double-click is a success rather than a support ticket.
Both tools
17. Dry by default, with a per-tool kill switch. CHECKOUT_MODE and
STOCK_ALERT_MODE are dry unless explicitly set to live; off removes the
tool from tools/list entirely. A dry call runs every check and then answers
with exactly what it would have done — including "this would have been BLOCKED,
here is why". Without it: a write tool that arms itself by being deployed has
no rehearsal — its first real invocation is in production, against money. Only
the exact string live reaches live, so a typo fails towards doing nothing.
18. A tool that cannot be honest is not announced — and that covers the read
tools. tools/list is capability-gated end to end: no getQuote, no
get_quote; no OpsAdapter, no back-office tools even for an authorized
caller; no CheckoutAdapter and InventoryAdapter and pricing, no
create_checkout. Anything not announced answers -32601, the same way, for
every reason. Without it: you ship stubs. The WooCommerce adapter used to
implement three methods it could not answer — listBrands returning [],
getColorCard returning null, getQuote rejecting every line — purely to
satisfy the interface, and the server announced all three to every anonymous
agent: a chart list that is always empty, a lookup that always says "not
found", a quote that always fails. A dead end an agent walks into twice is
worse than a tool that is not there.
19. The limit description matches the limit. The error names both tools and says the quota is shared; the tool descriptions say the same, including the case where the deployment cannot identify callers. Without it: an agent alternating the two tools hits a wall earlier than announced, concludes the counter is per-tool, and retries — the rate limit generating the traffic it exists to stop. For an MCP server, tool descriptions and error strings are the agent-facing API, and a mis-described limit is paid in retries.
20. The rate-limit key cannot be chosen by the caller. See below.
About that rate limiter (the honest version)
Two things are usually wrong with "rate limit by IP", and the second one is rarely mentioned.
The key. Everyone knows not to trust the first x-forwarded-for entry.
The part that gets missed: a forwarding header is written by a proxy, and
with no proxy in front of you — node server.js on a VPS, a bare next start, nginx without proxy_set_header, a container with a public port —
the whole header, last hop included, is a string the caller typed. Rotating
X-Forwarded-For: 203.0.113.1, .2, .3… then mints a fresh bucket per request
and the limiter does nothing. So this server trusts no forwarding header
unless you name the one your edge writes:
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
Naming a header asserts two things: your edge overwrites it, and nothing
else can reach the origin. If the origin is publicly reachable, that header is
forgeable by whoever finds it — lock the origin down first (deployment
protection, a firewall, mTLS). With nothing declared, the --http server uses
the TCP peer address, which nobody can forge; a serverless Fetch handler has
no socket to ask, so callers are unattributed — and an unattributed
deployment gets a process-wide ceiling of 60 writes/minute rather than a
per-caller promise it cannot keep. (Not a shared 5/min: collapsing every
caller into one small bucket turns the rate limiter into a denial of service
against your own customers, which is a worse bug than the one it fixes.)
The store. The bundled counter is a Map in the memory of one process. On
serverless that means N warm instances = N independent quotas, a cold start
resets it, and a redeploy erases it. It is friction against an agent loop, not
a WAF and not an abuse control, and it is labelled that way in
src/lib/ratelimit.ts rather than presented as a ceiling the team does not
actually have. rateLimited(key, max, windowMs) is a small synchronous port
with a single call-site shape, so swapping in Redis/KV/Durable Objects is
mechanical. Do that before you rely on the counter for anything.
Which is exactly why the limits that matter are attached to the effect:
fail-closed availability, the atomic reservation, the out-of-stock
precondition, dedupe at the point of the send, double opt-in, and the kill
switch. Those hold no matter how many instances are running, because they are
enforced by your data, not by a counter. The per-recipient email quota sits in
between: durable when your NotifyAdapter implements countOptInEmails,
best-effort otherwise — and the tool response says which one you have rather
than leaving you to guess.
Configuration for the write tools
| Env var | Default | What it does |
|---|---|---|
CHECKOUT_MODE | dry | off | dry | live for create_checkout |
STOCK_ALERT_MODE | dry | off | dry | live for subscribe_stock_alert |
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) |
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 |
STOCK_ALERT_CONFIRM_URL | ${NEXT_PUBLIC_SITE_URL}/api/stock-alert/confirm | Where the confirmation page lives. With neither set, live mode sends nothing |
STOCK_ALERT_PAGE_LOCALE | en | Language of that page (en | es) — the one screen a customer sees |
TRUSTED_PROXY_HEADER | (unset) | Name of the header your edge writes with the client IP. Unset ⇒ forwarding headers are ignored |
A tool is announced only when the active adapter provides the capability, so none of the above resurrects a tool the adapter cannot honor.
Trying the guard rails in 60 seconds
The bundled toy catalog is arranged so every state shows up.
chromaflow-classic-set-12 has 4 units; CF-G09 is a catalogued variant
with no inventory row; fieldbook-sketch-a5 is published as in_stock
with zero units (the oversell shape itself); and fieldbook-sketch-a4 is
the ordinary case — one product with 60 units addressable both by its slug
and by its 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
Quickstart as a web endpoint (2 minutes)
To serve MCP from your own domain (the deployable Next.js route):
git clone <this repo> && cd storefront-mcp
npm install
npm run dev
That's it — the default memory adapter serves the toy catalog in
examples/toy-catalog.json (a fictional store, "Demo Art Supply"). Try it:
# 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":{}}}'
To connect it to Claude Code: claude mcp add --transport http my-store http://localhost:3000/api/mcp.
Deploying it? Set TRUSTED_PROXY_HEADER to the header your platform writes
(x-vercel-forwarded-for on Vercel, cf-connecting-ip behind Cloudflare), or
the write quota becomes a single ceiling for the whole instance — see
the rate limiter.
Writing your own adapter
The protocol layer never touches data directly. It calls the interfaces
defined in src/lib/adapter.ts. Two methods are required. Everything else
is a capability, and capabilities decide which tools exist:
CatalogAdapter(required) —searchProductsandgetProduct, and that is the whole obligation. Public by definition: assume every byte it returns is world-readable. Optional on the same interface:getPromotions→get_promotionsgetQuote→get_quote(and it is a precondition forcreate_checkout)listVariantCharts+getVariantChart→ the two chart tools, announced together or not at all. A "variant chart" is one product line whose stock lives per variant — color, size, grit, roast, capacity. If your catalog has no such axis, do not implement them; there is nothing to stub.
OpsAdapter—getStockBulk,getTopProducts,getRecentOrders,getOrderStatus,getSalesSummary. Enables the token-gated tools.InventoryAdapter—getAvailability(refs)returning{units, verified, reason, pool}per line. This is the "how many units right now" source, and it must be the same one your read tools use. Optional but load-bearing:reserve(req)/release(id), without which live checkout refuses (guard rail 6).CheckoutAdapter—createCheckout(draft). Announced only alongside anInventoryAdapterand agetQuote.NotifyAdapter—isSubscribed/sendOptInEmail/confirmSubscription, plus the optionalcountOptInEmailsthat turns the per-recipient quota into a real ceiling.
An adapter that implements only the two required methods keeps working exactly as expected: everything else is simply never announced. That is a supported configuration, not a degraded one.
Steps:
- Copy
src/lib/adapters/memory.ts(the reference implementation) to a new file and point it at your database / API / ERP. - Register it in
src/lib/adapters/index.tsand select it with theCATALOG_ADAPTERenv var. - Keep the contract's honesty rules:
- return
units: null/stock: nullwhen you could not verify availability — never invent a number, and never fall back to 0; - return a
poolon every availability row: the identity of the pile of units that line draws from, with a product's slug and its SKU resolving to the same pool. Without it, one product ordered two ways is checked twice against the same stock; - implement
reserveas ONE atomic operation (UPDATE … WHERE (on_hand − reserved) >= :qty), all-or-nothing, andreleasefor the rollback; - serve every stock answer from one source, so the write path cannot validate against something different from what the customer was shown;
- subtract what is already committed elsewhere (open holds, other channels) — "on the shelf" is not "sellable to this customer";
- let
isSubscribedthrow on failure instead of returningfalse; the caller fails closed and sends nothing; - set a per-call timeout so a hung backend degrades into a note instead of a hung agent;
- keep
get_quotecharge-free, makegetQuoteecho theslug/skuit was given on each priced line, and make it price the quantity it was asked for or reject the line — the checkout path compares the two and refuses the cart when they disagree.
- return
A WooCommerce skeleton (src/lib/adapters/woocommerce.ts) is included,
built on the public Store API. It implements exactly three tools' worth of
catalog (search_products, get_product, get_promotions) and stubs
nothing; the TODO blocks describe what each remaining contract needs from a
WooCommerce install, including the two decisions — per-variant stock semantics
and how to hold units — that nobody can make for you.
Discovery: getting found
Agents can only call what they can find. Two artifacts, templates in
discovery/:
/.well-known/mcp.json— machine-readable descriptor (discovery/well-known-mcp.json; replace{{DOMAIN}}, serve frompublic/.well-known/mcp.json). List only public tools in it, and only the ones your deployment actually announces./llms.txt— human/LLM-readable site guide (discovery/llms-txt-snippet.md); includes an agent policy section: re-check stock before closing a sale, quotes never charge,stock: nullmeans unknown, quoteamount_to_payrather thansubtotal.
Additionally, GET /api/mcp returns a JSON descriptor so anyone poking the
endpoint understands what it is.
For the official MCP Registry,
server.json at the repo root is the manifest: it points at the
storefront-mcp npm package with stdio transport, so registry clients can
run it via npx. Publish to npm first — the registry validates the
mcpName inside the published tarball, so registering a version npm does not
have yet creates an entry pointing at nothing. The workflow in
.github/workflows/publish-mcp-registry.yml checks that before it runs.
Serving MCP from your WordPress domain
If your storefront runs WordPress/WooCommerce but the MCP server deploys
elsewhere (e.g. Vercel), wordpress-proxy/mcp-proxy.php is a mu-plugin
that serves https://yourshop.com/api/mcp by proxying to the upstream:
- hooks
initat priority 0 (answers before WordPress routing), - forwards POST bodies and the
Authorizationheader untouched (the upstream enforces the privilege split), - forwards the real client address in
X-Storefront-Client-IP, overwriting anything the caller sent, - handles CORS preflight, answers GET with a readable descriptor,
- caps payloads at 256 KB,
- on upstream failure returns a JSON-RPC error object — never an HTML error page, because the client is a program.
Install: drop the file in wp-content/mu-plugins/ and define
STOREFRONT_MCP_UPSTREAM in wp-config.php. Then set
TRUSTED_PROXY_HEADER=x-storefront-client-ip on the upstream — without
it, every request arrives wearing the WordPress server's address and the
write quota becomes 5 calls per minute for the entire store, so one looping
agent locks every customer out of checkout. Only trust that header if the
upstream cannot be reached except through the proxy; if it is publicly
reachable, anyone who finds it can write the header themselves.
Why not just Shopify's MCP?
If you are on Shopify: Shopify already gives every store a hosted MCP endpoint
with a generic search_catalog-style tool, and it is good. Use it. This
template is for the cases it does not cover:
- You are not on Shopify — WooCommerce, custom stack, headless, an ERP from 2009 that somehow still works.
- Your differentiator is a tool the platform will never generate. The
worked example here is
get_variant_chart: the full variant chart of a product line with live stock per variant. Any store can say "we sell these markers"; only the store that wired its own inventory can say "shade W3 is in stock right now, shade R21 is not". That per-variant answer closes sales, and it needs domain knowledge no generic platform tool has. - You want the privilege-separated back office — the same endpoint, with a token, answering "what were my top sellers this month?" to you while showing agents only the shop window.
- You want write tools you can actually defend. A hosted platform decides for you what its checkout tool checks. Here the refusals are in your repo, reviewable, and the ones that protect a third party are not configurable.
Repository layout
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
Breaking changes in 2.0
get_color_card→get_variant_chart(argumentbrand→chart) andlist_brands→list_variant_charts. The old names described one store's domain, not the contract; the payload renamescolors→variantsandhex→ optionalswatch_hex.CatalogAdapternow requires onlysearchProductsandgetProduct. The other four methods are optional, and each one gates its tool.AvailabilityRow.poolis new. Adapters that do not return it still work, but cannot catch slug/SKU collisions (guard rail 3).- Live
create_checkoutrequiresInventoryAdapter.reserveunlessCHECKOUT_UNRESERVED=allow(guard rail 6). - Forwarding headers are ignored unless
TRUSTED_PROXY_HEADERnames one (guard rail 20). STOCK_ALERT_SIGNING_SECRETmust be at least 32 characters, and live alerts now require a configured confirmation URL.
License
Apache-2.0 — see LICENSE and NOTICE.
storefront-mcp (Español)
Plantilla de servidor MCP para tiendas online. Los agentes de IA ven tu catálogo; tu operación la ves solo tú.
Partir en 30 segundos
npx storefront-mcp
Eso levanta un servidor MCP por stdio con un catálogo de demostración (el
adaptador memory) y 8 tools públicas: 6 de lectura más las dos de
escritura, que arrancan en modo dry (corren todas las verificaciones y no
crean nada). Para conectarlo a Claude Desktop
o Claude Code, agrega esto a tu configuración MCP (o ejecuta
claude mcp add storefront -- npx storefront-mcp):
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"]
}
}
}
¿Quieres también las 5 tools de trastienda? En stdio no existe el header
HTTP, así que la llave es la presencia de MCP_SECRET en el entorno del
proceso del servidor (quien lanza el proceso es dueño de la máquina donde
corre):
{
"mcpServers": {
"storefront": {
"command": "npx",
"args": ["storefront-mcp"],
"env": { "MCP_SECRET": "cualquier-valor-no-vacio" }
}
}
}
¿Prefieres curl? npx storefront-mcp --http 8787 sirve el mismo contrato
JSON-RPC por HTTP en localhost, con el chequeo real de
Authorization: Bearer <MCP_SECRET> (mismo comportamiento que la ruta de
Next.js) y además la página de confirmación de opt-in en
/api/stock-alert/confirm. El adaptador se elige con CATALOG_ADAPTER
(memory por defecto, woocommerce para el esqueleto de la Store API).
Qué es
Un servidor MCP empaquetado como ruta de Next.js (App Router) que expone una tienda online a agentes de IA (Claude, GPTs personalizados, frameworks de agentes — cualquier cliente MCP sobre Streamable HTTP). Define 13 tools y anuncia el subconjunto que tu adaptador puede responder de verdad:
- 6 públicas de lectura:
search_products,get_product,get_variant_chart,list_variant_charts,get_promotions,get_quote. - 2 públicas de escritura, con guard rails:
create_checkoutysubscribe_stock_alert. - 5 sensibles protegidas por token:
get_stock_bulk,get_top_products,get_recent_orders,get_order_status,get_sales_summary.
Solo search_products y get_product están siempre. Todo lo demás es una
capacidad: si implementas el método del adaptador la tool aparece; si no, esa
tool no existe en tu despliegue (ver guard rail 18).
Las tools de escritura son públicas a propósito — que un agente compre por encargo de una persona es justamente el punto — así que su protección está en el comportamiento, no en un token. Arrancan en modo dry. Ver los guard rails.
Está extraído de un servidor de tienda en producción, con todo lo específico de esa tienda removido y reemplazado por una interfaz de adaptadores.
Por qué
Los agentes de IA se están convirtiendo en un canal de venta. Cuando alguien le pide a su asistente "búscame un marcador gris cálido con stock", ganan las tiendas que el agente puede consultar de verdad: búsqueda estructurada, disponibilidad real, una cotización con link de pago. Un endpoint MCP público es la forma de aparecer en esa conversación — en tu propio dominio, con tus datos y tus reglas.
El diseño central: separación de privilegios
Un agente puede mirar la vitrina; nunca ve la operación.
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
MarkItDown
Freeby Microsoft · Content & Media
Convert files (PDF, Word, Excel, images, audio) to Markdown for LLM consumption
