Building an MCP server for WooCommerce: a practitioner’s guide
Model Context Protocol gives an AI agent a typed, introspectable surface to act on a store. WooCommerce already exposes a REST API under /wp-json/wc/v3/. Putting MCP in front turns that REST surface into something an LLM can call without guessing parameter names. This is the architecture I ship for clients who want their catalogue and order intent reachable from Claude, ChatGPT, or a custom agent runtime.
This article anchors to the MCP server development service and the Universal Commerce Protocol pillar.
TL;DR
- MCP sits in front of WooCommerce REST as a typed JSON-RPC surface.
- Three tools cover most agent needs:
catalogue.list,product.detail,order.intent. - Zod schemas define every input and output and run on every call.
- Map WooCommerce fields to
schema.orgProduct and Offer so agent answers stay consistent across surfaces. - Cloudflare Workers is the deployment target I default to for read-heavy MCP servers.
What MCP actually is
Anthropic announced Model Context Protocol on 25 November 2024 (anthropic.com/news/model-context-protocol). It is an open protocol over JSON-RPC 2.0 that lets a client (an LLM host like Claude Desktop, an IDE, or a custom agent) talk to a server (your MCP implementation) over stdio or HTTP with Server-Sent Events.
The three primitives are:
- Tools. Callable functions with typed inputs and outputs. The agent picks one and calls it.
- Resources. Read-only references to documents or records the agent can pull into context.
- Prompts. Server-supplied prompt templates the host can invoke.
For a WooCommerce store, tools carry most of the weight. Catalogue browsing is a tool call. Product detail is a tool call. Proposing an order is a tool call. Resources help when the agent needs the full text of a returns policy or a long product description; prompts help when you want to ship preset interactions (“recommend three products for this customer profile”).
The intent inventory comes first
Before any code, I write down the intents an agent should be able to express against the store. For a typical WooCommerce build the list is short:
- Browse the catalogue with filters (
catalogue.list). - Fetch the full detail of one product (
product.detail). - Propose an order on behalf of a customer, returning a draft for human confirmation (
order.intent). - Look up the status of an existing order by number and email (
order.status). - Query stock for an SKU (
inventory.check).
Each intent maps to exactly one tool. Resist the temptation to expose wc.products.get, wc.products.list, wc.orders.create as a one-to-one wrap of the REST surface. The agent does not benefit from REST verbs; it benefits from intent verbs.
Tool definitions with Zod
The TypeScript SDK at @modelcontextprotocol/sdk uses Zod (zod.dev) for input and output schemas. Here is the contract for catalogue.list:
import { z } from "zod";
export const catalogueListInput = z.object({
query: z.string().min(2).max(200).optional(),
category: z.string().optional(),
in_stock: z.boolean().optional(),
price_min: z.number().nonnegative().optional(),
price_max: z.number().nonnegative().optional(),
page: z.number().int().min(1).max(100).default(1),
per_page: z.number().int().min(1).max(50).default(12),
});
export const catalogueListOutput = z.object({
results: z.array(
z.object({
sku: z.string(),
name: z.string(),
url: z.string().url(),
price: z.object({
amount: z.number().nonnegative(),
currency: z.string().length(3),
}),
availability: z.enum(["InStock", "OutOfStock", "PreOrder"]),
image: z.string().url().optional(),
})
),
total: z.number().int().nonnegative(),
page: z.number().int(),
});
Two things matter here. First, the output uses schema.org vocabulary (InStock, OutOfStock, PreOrder are direct values from ItemAvailability). Second, the input rejects garbage at the protocol boundary; the handler never sees a query: "" or a negative price_min.
I declare every tool the same way: input schema, output schema, handler. The handler is a thin adapter over /wp-json/wc/v3/. Zod runs twice per call: once on input, once on output. The output check catches handler bugs that would otherwise leak malformed data to the agent.
Bridging to the WooCommerce REST API
The handler for catalogue.list reads from /wp-json/wc/v3/products:
async function handleCatalogueList(input: z.infer<typeof catalogueListInput>) {
const params = new URLSearchParams();
if (input.query) params.set("search", input.query);
if (input.category) params.set("category", input.category);
if (input.in_stock) params.set("stock_status", "instock");
if (input.price_min !== undefined) params.set("min_price", String(input.price_min));
if (input.price_max !== undefined) params.set("max_price", String(input.price_max));
params.set("page", String(input.page));
params.set("per_page", String(input.per_page));
const response = await fetch(
`${WC_BASE}/wp-json/wc/v3/products?${params.toString()}`,
{ headers: { Authorization: `Basic ${WC_AUTH}` } }
);
const products = await response.json();
const total = Number(response.headers.get("X-WP-Total") ?? 0);
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrg),
total,
page: input.page,
});
}
The mapWooProductToSchemaOrg helper converts WooCommerce field names (stock_status, regular_price, permalink) to the schema.org-aligned shape declared in the output schema. Field mapping in one place, called from one tool, called from one handler. Drift between WooCommerce upgrades and the agent contract gets caught by the output parse call, not in production.
For order.intent, I never call WooCommerce write endpoints from inside the handler. The tool returns a draft order object that the host displays to the user, the user confirms, and only then a separate authenticated path calls POST /wp-json/wc/v3/orders. Writing on speculation from an LLM is the wrong default.
schema.org alignment is load-bearing
Two reasons to align tool outputs with schema.org vocabulary:
Agents reuse the same words across sources. When an agent answers “is this product in stock?” it stitches a Product entity from your MCP server, an Offer with availability, and possibly a Review. If your MCP server returns { "stock": "yes" } and the agent’s other source returns availability: "https://schema.org/InStock", the agent has to reconcile two vocabularies. With schema.org alignment on your end, it does not.
Your storefront’s structured data already speaks schema.org. Your /wp-content/themes/<theme>/single-product.php (or the headless equivalent) emits Product JSON-LD. Keeping the MCP output in the same shape means the same product page renders the same data to a Google crawler, an OpenAI crawler via the URL, and an agent calling your MCP tool directly.
The mapping for a typical WooCommerce product looks like this:
| WooCommerce field | schema.org field |
|---|---|
sku | sku |
name | name |
permalink | url |
regular_price + currency | offers.price + priceCurrency |
stock_status: "instock" | availability: InStock |
stock_status: "outofstock" | availability: OutOfStock |
images[0].src | image |
description | description |
Variations need an extra layer (hasVariant array of Product), but the base shape is the same.
Idempotency and side effects
Read tools (catalogue.list, product.detail, inventory.check, order.status) are naturally idempotent. Calling them three times yields the same answer.
order.intent is the trap. The agent can call it twice if the network glitches between the response and the host. The contract I ship:
order.intentaccepts an optionalidempotency_key(a UUID supplied by the host).- The handler stores the key in Workers KV with a 24-hour TTL alongside the resulting draft order ID.
- A repeat call with the same key returns the same draft instead of creating a new one.
This pattern matches how Stripe handles idempotency on POST /v1/charges. It is well understood; agents and humans both benefit.
Deploying to Cloudflare Workers
The MCP TypeScript SDK exports a Server class plus transport adapters. For Workers, I use the streamable HTTP transport. The Worker entry looks like this:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const auth = request.headers.get("Authorization");
if (!isValidAgentToken(auth, env)) {
return new Response("Unauthorized", { status: 401 });
}
const server = createWooCommerceMcpServer(env);
const transport = new StreamableHTTPServerTransport(request);
return server.connect(transport);
},
};
isValidAgentToken checks against scoped tokens stored in Workers KV. Read-only tools go through with a less-privileged token; order.intent requires a token scoped to the orders:write capability. The auth strategy is one of the two patterns covered in the MCP authentication patterns guide.
wrangler.toml declares the WordPress origin URL, the WooCommerce consumer key and secret (as Worker secrets, never in code), and the KV namespace for idempotency. Deploy is wrangler deploy. The Worker is small (well under the 1 MB compressed bundle limit) because it is just JSON-RPC routing plus REST adapters.
Observability is non-optional
Every tool call gets logged with:
- The tool name.
- A SHA-256 hash of the input (not the input itself; PII risk).
- The latency.
- Whether output validation passed or failed.
- The agent token scope.
The logs go to Cloudflare Logpush into a long-term store. Two operational habits this enables:
Schema tightening. Six weeks after launch I review the validation failures. Every output failure is a handler bug or a missed WooCommerce field. Every input failure is either a malformed agent or a schema that is too strict. Both get fixed in the next release.
Cost attribution. Tool calls are billable load on the WooCommerce origin. The log tells me which agent token is generating the load and which tool. A single misbehaving agent that loops on catalogue.list is one log query away from being identified.
Where this fits in the cluster
This article is the implementation walkthrough. For the typed-tool deep dive see writing typed catalogue tools with Zod for MCP. For the auth patterns see MCP authentication patterns: OAuth, tokens, and when to use each. For the protocol-level decision between MCP and REST see MCP vs REST: when each wins for AI agent integration. For the migration path from an existing API see migrating an existing WordPress API to MCP: a 4-week playbook. The pillar is MCP server development and the broader strategy sits under the Universal Commerce Protocol.
Pricing is individual because the scope depends on how many tools you need, the complexity of the WooCommerce extensions involved, and the agent runtimes you intend to support.
