Building an MCP server for WooCommerce: a practitioner's guide

Building an MCP server for WooCommerce: a practitioner's guide

Last verified: October 24, 2026
18 min read
Guide
500+ WP projects
AI integration

#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 guide for AI shopping agents (UCP vs this MCP build path). The scoped service pillar remains Universal Commerce Protocol.

#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.org Product 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 Streamable HTTP, the HTTP transport that replaced the older HTTP with Server-Sent Events in the 2025-03-26 revision of the specification.

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”).

#What the MCP 2026-07-28 specification changes for a WooCommerce server

The current revision of the specification is dated 28 July 2026 (changelog). Four of its changes matter for a server that sits in front of a store.

The protocol no longer has sessions. The initialize handshake is gone, and so is the Mcp-Session-Id header in the Streamable HTTP transport. Every request carries its protocol version and client capabilities in _meta. A Worker that builds a fresh server for every request fits this model, because there is no connection state to keep between calls.

State travels as an argument. If a server needs state across calls, the specification recommends returning an explicit handle from a creation tool and accepting it as an argument on later calls (stateful tools). Its own example is a shopping basket with a basket_id. The draft that order.intent returns follows the same pattern: the draft ID is the handle. The same section asks servers to check the caller’s authorization against the handle on every call, to state the lifetime in the tool description (24 hours here), and to return a tool execution error for an expired or unknown handle so the model can create a new one.

The server must implement server/discover, the method it uses to advertise its supported protocol versions, its capabilities and its identity.

List results can be cached. tools/list results now carry ttlMs and cacheScope, and the server should return tools in a deterministic order. Five tools that only change with a deploy can take a long ttlMs.

The HTTP+SSE transport has been deprecated since the 2025-03-26 revision, and new servers use Streamable HTTP. Version 2 of the TypeScript SDK serves both protocol generations from the same entry point (protocol versions in the SDK docs), so a client that still opens with initialize keeps working.

#Which MCP tools a WooCommerce store needs

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:

  1. Browse the catalogue with filters (catalogue.list).
  2. Fetch the full detail of one product (product.detail).
  3. Propose an order on behalf of a customer, returning a draft for human confirmation (order.intent).
  4. Look up the status of an existing order by number and email (order.status).
  5. 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.

#How to define MCP tools with Zod

The TypeScript SDK (package @modelcontextprotocol/server in version 2; version 1 shipped as @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", "BackOrder", "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, BackOrder, 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.

#How to register MCP tools with annotations and structured output

In version 2 of the SDK the server-side package is @modelcontextprotocol/server, and each tool is registered with registerTool, which takes a name, a config and a handler (tools in the SDK docs). With the schemas above, catalogue.list looks like this:

import { McpServer } from "@modelcontextprotocol/server";

export function createWooCommerceMcpServer(env: Env): McpServer {
  const server = new McpServer({ name: "woocommerce", version: "1.0.0" });

  server.registerTool(
    "catalogue.list",
    {
      title: "Browse the catalogue",
      description: "List store products with filters and pagination",
      inputSchema: catalogueListInput,
      outputSchema: catalogueListOutput,
      annotations: { readOnlyHint: true },
    },
    async (input) => {
      const output = await handleCatalogueList(input);
      return {
        content: [{ type: "text", text: JSON.stringify(output) }],
        structuredContent: output,
      };
    }
  );

  // product.detail, inventory.check, order.status, order.intent
  return server;
}

When a tool declares an outputSchema, the specification requires the server to return structuredContent that conforms to it, and the client should validate it (tools in the specification). The SDK runs that validation before the result leaves the server. For older clients, the specification recommends also returning the serialized JSON in a text block, which is the content in the example.

Annotations describe how the tool behaves. readOnlyHint says it does not modify its environment. destructiveHint (default true) and idempotentHint (default false) only count when readOnlyHint is false. openWorldHint (default true) says whether the tool interacts with an open world of external entities. The four read tools carry readOnlyHint: true. order.intent carries readOnlyHint: false and destructiveHint: false, because it creates a draft and deletes nothing. I leave idempotentHint at false, because the idempotency_key is optional and two identical calls without it create two drafts. According to the SDK docs, a host can auto-approve a read-only tool and ask for confirmation before a destructive one. The specification also requires clients to treat annotations as untrusted unless they come from a trusted server, so the token scope check stays on the server.

Dotted names such as catalogue.list follow the specification’s naming guidance: 1 to 128 characters, using only ASCII letters, digits, underscore, hyphen and dot. Failures go through two channels. An unknown tool or a malformed request is a protocol error and travels as a JSON-RPC error. A failing WooCommerce API call or an out-of-range argument is a tool execution error and travels inside the result with isError: true and a message the model can use to correct itself. A product.detail call with a SKU that does not exist should return the second kind, with a message saying the SKU does not exist, so the model can change the argument and retry.

#How to connect an MCP server 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.

#How to create WooCommerce REST API keys for the MCP server

The MCP server talks to WooCommerce with a pair of REST API keys: the Consumer Key and the Consumer Secret. According to the official documentation (REST API authentication), you create them under WooCommerce > Settings > Advanced > REST API (wp-admin/admin.php?page=wc-settings&tab=advanced&section=keys). Three properties of those keys shape the server design.

Each key belongs to a WordPress user, and requests made with it follow that user’s roles and capabilities. If the user is deleted, the key stops working and is not transferred to another user. That is why I create a dedicated technical user for the MCP server instead of hanging the key off the account of a person who will one day leave the company.

The access level is Read, Write or Read/Write. The read tools use a read-only key. Only the separate route that confirms the order with POST /wp-json/wc/v3/orders knows the key with write access.

Over HTTPS, authentication is HTTP Basic, with the Consumer Key as the username and the Consumer Secret as the password. That is the Authorization: Basic header in the catalogue.list handler.

Pagination comes from the same documentation (pagination). By default the API returns 10 items per page, per_page and page change that, and the X-WP-Total and X-WP-TotalPages headers carry the total number of resources and pages. The handler reads X-WP-Total to fill total.

Two details of the products endpoint (list all products) affect the schema. The category parameter filters by category ID, not by slug, so the agent needs IDs or the server has to translate the slug before calling. And stock_status accepts instock, outofstock and onbackorder. schema.org has BackOrder for the third value, which is why the output enum of catalogue.list includes it: without it, a product on backorder would fail output validation.

Update, : WooCommerce announced on 21 October that version 11.3 removes its own MCP endpoint, /wp-json/woocommerce/mcp, together with the X-MCP-API-Key header that authenticated it with a REST API key. The server in this guide does not use that endpoint. It calls /wp-json/wc/v3/ with consumer keys, and that path stays. If you also connected an MCP client to the built-in endpoint, move it before upgrading: install the MCP Adapter plugin, create an application password for a user with only the capabilities the client needs, and point the client at /wp-json/mcp/mcp-adapter-default-server. The external server pattern carries over to a Shopify store or a headless backend, because only the handlers that call the store change.

Update, : WooCommerce 11.3 also adds an experimental API for extensible abilities (WooCommerce developer blog, 22 October 2026). Extensions can attach their own fields, query filters and validators to the product, order and order item abilities, so an agent reads and changes extension data through the abilities it already calls. The feature is off by default, is switched on under WooCommerce > Settings > Advanced > Features in the experimental section, and WooCommerce asks developers not to use it in production yet. For an external server like this one nothing changes. It starts to matter when extension data, such as custom product fields, should reach agents through the adapter.

#How to map WooCommerce fields to schema.org

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 fieldschema.org field
skusku
namename
permalinkurl
regular_price + currencyoffers.price + priceCurrency
stock_status: "instock"availability: InStock
stock_status: "outofstock"availability: OutOfStock
stock_status: "onbackorder"availability: BackOrder
images[0].srcimage
descriptiondescription

Variations need an extra layer (hasVariant array of Product), but the base shape is the same.

#How to make MCP order tools idempotent

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.intent accepts an optional idempotency_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.

#How to deploy an MCP server on Cloudflare Workers

Version 2 of the TypeScript SDK exports createMcpHandler, which returns a { fetch } object in the shape Cloudflare Workers expects as a default export and serves the Streamable HTTP transport (web-standard runtimes in the SDK docs). The Worker entry looks like this:

import {
  createMcpHandler,
  hostHeaderValidationResponse,
  originValidationResponse,
} from "@modelcontextprotocol/server";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const rejected =
      hostHeaderValidationResponse(request, [env.MCP_HOSTNAME]) ??
      originValidationResponse(request, [env.SHOP_HOSTNAME]);
    if (rejected) return rejected;

    const authInfo = await verifyAgentToken(request, env);
    if (!authInfo) {
      return new Response("Unauthorized", { status: 401 });
    }

    const handler = createMcpHandler(() => createWooCommerceMcpServer(env));
    return handler.fetch(request, { authInfo });
  },
};

verifyAgentToken checks against scoped tokens stored in Workers KV and returns the token data, which handlers read as ctx.http.authInfo. 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 stays small because it is just JSON-RPC routing plus REST adapters.

#How to secure the MCP endpoint with OAuth and Origin validation

Authorization is optional in MCP. When an HTTP-based implementation supports it, it should follow the authorization specification (authorization); a stdio server should not follow it and takes its credentials from the environment instead. These are the rules that affect this Worker.

The MCP server acts as an OAuth 2.1 resource server and must publish OAuth 2.0 Protected Resource Metadata (RFC 9728), so the client can discover which authorization server issues its tokens.

It must validate that the token was issued for it as the audience, as defined in RFC 8707. An invalid or expired token gets an HTTP 401.

It must not accept or pass through any other token. For a store, that means the agent’s token never reaches /wp-json/wc/v3/. Only the REST API keys stored as Worker secrets travel to WooCommerce.

The transport adds one more obligation (Streamable HTTP): the server must validate the Origin header on all incoming connections to prevent DNS rebinding attacks, and respond with 403 when the header is present and invalid. createMcpHandler does not do that check on its own. The SDK docs say to put hostHeaderValidationResponse and originValidationResponse in front of it, which is what the Worker above does. A request without an Origin header passes that check, so the token remains the main barrier.

Another transport change helps with the logging in the next section. Every POST carries the Mcp-Method and Mcp-Name headers, and for tools/call the latter holds the tool name, so a proxy or the Worker itself can record which tool was called without parsing the JSON body.

Update, : every plugin that brings MCP into WordPress adds attack surface of its own. Wordfence published three such entries on 15 and 16 October 2026: AI Engine up to 3.8.3 missed a capability check, so a logged-in contributor could perform an unauthorised action (CVE-2026-110888); the wsp_get_posts MCP tool in WSP MCP up to 2.9.5 let any logged-in subscriber read titles, authors and excerpts of draft, pending and scheduled posts (CVE-2026-109382); and xSpeed Cache up to 1.4.2, a caching plugin with MCP features, allowed stored cross-site scripting by contributors (CVE-2026-109985). None of the three needs an administrator account. The Worker in this guide runs outside WordPress and reaches the store only with its REST API keys, so a WordPress role does not open any of its tools. What protects it are the token and Origin checks described above.

#How to log and monitor MCP tool calls

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.

#Other MCP and WooCommerce guides

This article is the implementation walkthrough. For enterprise-scale ERP synchronization, Redis queues, and database locking patterns behind WooCommerce stores, see my comprehensive WooCommerce ERP integration architecture guide 2026. For the shipped open-source read-only package see WooCommerce MCP open source: read-only store access for AI agents. 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. The people behind the open-source package are the same WordPress developer practice that ships the store-side work.

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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you are planning a Headless WordPress setup, frontend decoupling, or migration to Astro, I can design and build the architecture, API, and frontend.

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

What is MCP and why use it for WooCommerce?#
Model Context Protocol is a JSON-RPC based protocol introduced by Anthropic on 25 November 2024 for connecting AI agents to data and tools. Used in front of WooCommerce, an MCP server gives an agent typed actions like catalogue.list and order.intent instead of forcing it to guess which /wp-json/wc/v3/ endpoint to call.
Does MCP replace the WooCommerce REST API?#
No. MCP sits in front of the REST API. The tool handlers call /wp-json/wc/v3/ under the hood. REST stays the system of record. MCP is a typed surface that an LLM agent can introspect and call without bespoke prompt engineering.
Where do tool definitions live?#
In code, alongside the server. Each tool has a name, a Zod input schema, a Zod output schema, and a handler. The MCP SDK exposes tools/list to clients so the agent can discover what is callable at runtime.
Why Cloudflare Workers rather than a Node server?#
Workers gives global edge presence, a small attack surface, and a billing model that matches sporadic agent traffic. The MCP TypeScript SDK runs on the Web Platform APIs Workers exposes; no Node-only modules required.
How does the agent authenticate to the MCP server?#
For read-only tools, an unauthenticated transport plus IP allow lists is often enough. For order.intent and any mutating tool, a scoped API token issued by your store and verified on every request. The full pattern is in the MCP authentication patterns guide.

Need an FAQ tailored to your industry and market? I can build one aligned with your business goals.

Let’s discuss

Related Articles