Zod input/output schemas, idempotency keys, error responses, and typed contracts for MCP tools that wrap a WooCommerce catalogue.
EN

Writing typed catalogue tools with Zod for MCP

4.70 /5 - (7 votes )
Last verified: May 1, 2026
7min read
Guide
500+ WP projects
AI integration

#Writing typed catalogue tools with Zod for MCP

A typed contract is the difference between an agent that reliably calls your store and an agent that hallucinates parameter names. Zod (zod.dev) is the schema library the official @modelcontextprotocol/sdk uses; it gives runtime validation, JSON Schema generation for tools/list, and TypeScript type inference from a single source. This is the pattern I use for every MCP tool I ship.

This article anchors to the MCP server development service pillar.

#TL;DR

  • Write input and output schemas in Zod; let z.infer derive the handler signature.
  • Use .strict() so unknown keys fail fast at the protocol boundary.
  • Mirror schema.org vocabulary in outputs whenever a corresponding type exists.
  • Mutating tools take an optional idempotency_key; memoise by key in Workers KV.
  • Tool-level error envelope for domain errors; JSON-RPC errors for protocol failures.

#Naming a tool

Tool names form the public contract with every agent that ever calls your server. They need to read like verb-object intents, not like REST endpoints:

  • Good: catalogue.list, product.detail, order.intent, inventory.check, order.status.
  • Bad: wc_products_get, getProductById, searchCatalog.

Once an agent has been built against catalogue.list, renaming it to catalog.list breaks every consumer silently because the agent no longer finds the tool. Treat the name as a release commitment.

#Input schemas: tight and explicit

The default I write for every input schema:

import { z } from "zod";

export const catalogueListInput = z
  .object({
    query: z.string().min(2).max(200).optional()
      .describe("Free-text search across product name and SKU"),
    category: z.string().optional()
      .describe("Category slug as it appears in WooCommerce"),
    in_stock: z.boolean().optional()
      .describe("Restrict to products currently in stock"),
    price_min: z.number().nonnegative().optional()
      .describe("Lower bound on regular price, inclusive"),
    price_max: z.number().nonnegative().optional()
      .describe("Upper bound on regular price, inclusive"),
    page: z.number().int().min(1).max(100).default(1)
      .describe("Page number, 1-indexed"),
    per_page: z.number().int().min(1).max(50).default(12)
      .describe("Results per page, max 50"),
  })
  .strict()
  .refine(
    (v) => v.price_min === undefined || v.price_max === undefined || v.price_min <= v.price_max,
    { message: "price_min must be less than or equal to price_max" }
  );

Three things worth pulling out:

.describe() everywhere. The MCP SDK forwards the description into the JSON Schema returned by tools/list. The agent reads it. A field without a description is a field the agent has to guess at; a field with one is a field the agent gets right.

.strict() on the object. Unknown keys fail validation. An agent that sends { query: "boots", catgeory: "men" } (typo) gets a clear validation error pointing at catgeory instead of a successful call that silently ignores the misspelled filter.

.refine() for cross-field rules. Anything that depends on more than one field belongs in a .refine(). The price-range constraint is the canonical example; only-one-of constraints are the other.

#Output schemas: mirror the storefront

The output schema does two jobs: it documents the response shape for the agent, and it validates the handler’s return value before it leaves the server.

export const productSchemaOrgShape = z.object({
  "@type": z.literal("Product"),
  sku: z.string(),
  name: z.string(),
  url: z.string().url(),
  description: z.string(),
  image: z.string().url().optional(),
  brand: z.object({ "@type": z.literal("Brand"), name: z.string() }).optional(),
  offers: z.object({
    "@type": z.literal("Offer"),
    price: z.number().nonnegative(),
    priceCurrency: z.string().length(3),
    availability: z.enum([
      "https://schema.org/InStock",
      "https://schema.org/OutOfStock",
      "https://schema.org/PreOrder",
    ]),
    url: z.string().url(),
  }),
});

export const catalogueListOutput = z.object({
  results: z.array(productSchemaOrgShape),
  total: z.number().int().nonnegative(),
  page: z.number().int().min(1),
  per_page: z.number().int().min(1),
});

Two reasons to mirror schema.org explicitly:

Same vocabulary across surfaces. Your storefront emits Product JSON-LD on the product detail page. Your MCP product.detail tool returns the same shape. An agent stitching context from “the page I crawled at /shop/widget” and “what your MCP server told me” sees one vocabulary, not two.

Stable IDs in availability. https://schema.org/InStock is a URL; it is permanent, machine-readable, and unambiguous. A free-form string like "yes" or "in stock" is a translation problem.

The handler runs parse on the way out:

return catalogueListOutput.parse({
  results: products.map(mapWooProductToSchemaOrgProduct),
  total,
  page: input.page,
  per_page: input.per_page,
});

If the WooCommerce mapping returns a malformed Product, parse throws and the error is caught upstream. The agent never sees the malformed data; the developer sees a typed validation error in the log.

#Idempotency keys for mutating tools

Read tools are naturally idempotent. Mutating tools need an explicit key. The pattern Stripe established is the one I follow (Stripe idempotency reference):

export const orderIntentInput = z
  .object({
    customer: z.object({
      email: z.string().email(),
      name: z.string().min(1).max(200),
    }),
    items: z
      .array(
        z.object({
          sku: z.string(),
          quantity: z.number().int().min(1).max(999),
        })
      )
      .min(1)
      .max(50),
    shipping_method: z.string().optional(),
    notes: z.string().max(1000).optional(),
    idempotency_key: z.string().uuid().optional()
      .describe("Optional UUID. Repeated calls with the same key return the original draft order."),
  })
  .strict();

The handler:

async function handleOrderIntent(
  input: z.infer<typeof orderIntentInput>,
  env: Env,
): Promise<OrderIntentOutput> {
  if (input.idempotency_key) {
    const cached = await env.IDEMPOTENCY.get(`oi:${input.idempotency_key}`, "json");
    if (cached) return orderIntentOutput.parse(cached);
  }

  const draft = await createDraftOrder(input, env);

  if (input.idempotency_key) {
    await env.IDEMPOTENCY.put(
      `oi:${input.idempotency_key}`,
      JSON.stringify(draft),
      { expirationTtl: 60 * 60 * 24 }
    );
  }

  return orderIntentOutput.parse(draft);
}

24 hours is the default TTL because that is long enough for an agent retry storm to subside and short enough that KV stays small.

#The error envelope

Two kinds of errors, two transports:

Protocol errors. Method not found, invalid params, parse error. These travel as JSON-RPC error responses with codes -32700 through -32603 (JSON-RPC spec). The MCP SDK handles them automatically when Zod input validation fails.

Domain errors. Out of stock, customer not found, scope insufficient. These travel inside a successful tool response, in a structured error field. The agent treats them as data, reasons about them, and decides what to do next.

The envelope I use:

export const toolError = z.object({
  code: z.enum([
    "out_of_stock",
    "not_found",
    "forbidden",
    "rate_limited",
    "internal",
  ]),
  message: z.string(),
  retry_after_seconds: z.number().int().nonnegative().optional(),
  fields: z.record(z.string(), z.unknown()).optional(),
});

export const orderIntentOutput = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("ok"),
    draft_order_id: z.string(),
    total: z.number().nonnegative(),
    currency: z.string().length(3),
    estimated_delivery: z.string().datetime().optional(),
  }),
  z.object({
    status: z.literal("error"),
    error: toolError,
  }),
]);

The discriminated union forces the agent to check status before reading any other field. A “successful response with empty draft” never type-checks; either you ship status: "ok" with the order data, or status: "error" with the envelope.

#Type inference from the schemas

Schemas are the single source of truth. Types come out via z.infer:

type CatalogueListInput = z.infer<typeof catalogueListInput>;
type CatalogueListOutput = z.infer<typeof catalogueListOutput>;
type OrderIntentInput = z.infer<typeof orderIntentInput>;
type OrderIntentOutput = z.infer<typeof orderIntentOutput>;

The handler signature uses these directly:

async function handleCatalogueList(
  input: CatalogueListInput,
  env: Env,
): Promise<CatalogueListOutput> {
  // ...
}

If I ever change catalogueListInput to add a new required field, every call site that does not supply it fails at compile time. The TypeScript compiler enforces what the runtime validator already enforces; both lock onto the same Zod schema.

#Versioning the schemas

Schemas evolve. The rule that has held up across multiple deployments:

Backward-compatible additions are safe. A new optional field with a default value does not break existing agents.

Backward-incompatible changes require a new tool name. If catalogue.list ever needs a new required field or a renamed existing field, ship catalogue.list_v2 alongside it for at least 90 days. Mark catalogue.list deprecated in its description; remove it after the deprecation window.

The cluster reading section below documents which other articles in this set cover the surrounding decisions.

#Where this fits in the cluster

This article covers typed contracts. For the implementation walkthrough see building an MCP server for WooCommerce. For the auth strategy see MCP authentication patterns. For the protocol-level decision see MCP vs REST. For the migration pathway from an existing API see migrating an existing WordPress API to MCP. The pillar is MCP server development.

Pricing is individual because typed contract scope depends on the breadth of the agent intents you want to support and the complexity of the WooCommerce extensions you wrap.

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 visibility in Google and AI systems matters, I can build the content architecture, FAQ, schema, and internal linking needed for SEO, GEO, and AEO.

Related cluster

Explore other WordPress services and knowledge base

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

Article FAQ

Frequently Asked Questions

Practical answers to apply the topic in real execution.

SEO-ready GEO-ready AEO-ready 5 Q&A
Why Zod instead of plain JSON Schema?
The MCP TypeScript SDK accepts Zod schemas directly and converts them to JSON Schema for the tools/list response. Writing Zod gives type inference via z.infer, runtime validation, and a single source for both the SDK and the typed handler signature.
Should outputs really be validated?
Yes. The output parse catches handler bugs that would otherwise leak malformed data to the agent. A WooCommerce field rename in 9.x or a broken plugin filter shows up as a typed validation error in your logs, not as a confused agent making up details.
How strict should input validation be?
Strict enough that the handler never receives garbage. Use .strict() to reject unknown keys, narrow string lengths, bound numeric ranges, and enumerate finite sets. Document that strictness in the tool description so agents can reason about it.
How do idempotency keys interact with the MCP protocol?
The idempotency key is just an optional input field on mutating tools. The protocol does not know about it. The handler checks the key against a KV store, returns the previous result if present, otherwise executes and caches.
Where do error responses live in MCP?
MCP supports JSON-RPC error responses for protocol-level failures (invalid params, method not found) and tool-level error envelopes inside successful tool responses. I use the tool-level envelope for domain errors (out of stock, customer not found) so the agent can reason about them as data.

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

Let’s discuss

Related Articles

A practical walkthrough of building a Model Context Protocol server in front of WooCommerce. Tool definitions, catalogue and order endpoints, schema.org alignment, Zod validation, and a Cloudflare Workers deployment that an AI agent can talk to.
wordpress

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

A practical walkthrough of building a Model Context Protocol server in front of WooCommerce. Tool definitions, catalogue and order endpoints, schema.org alignment, Zod validation, and a Cloudflare Workers deployment that an AI agent can talk to.

A decision guide for picking between Model Context Protocol and a REST API when the consumer is an AI agent. Typed surface vs JSON shape inference, mutating actions, authentication, and the hybrid pattern that often beats both.
wordpress

MCP vs REST: when each wins for AI agent integration

A decision guide for picking between Model Context Protocol and a REST API when the consumer is an AI agent. Typed surface vs JSON shape inference, mutating actions, authentication, and the hybrid pattern that often beats both.

A working set of authentication patterns for Model Context Protocol servers. OAuth for human-delegated agent access, scoped API tokens for B2B and headless flows, when to require auth versus stay anonymous, rate limiting, and what to log.
wordpress

MCP authentication patterns: OAuth, tokens, and when to use each

A working set of authentication patterns for Model Context Protocol servers. OAuth for human-delegated agent access, scoped API tokens for B2B and headless flows, when to require auth versus stay anonymous, rate limiting, and what to log.