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.inferderive 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.
