Herramientas de catálogo tipadas con Zod para MCP
Un contrato tipado es la diferencia entre un agente que llama a tu tienda de forma fiable y un agente que alucina nombres de parámetros. Zod (zod.dev) es la biblioteca de esquemas que usa el @modelcontextprotocol/sdk oficial: aporta validación en tiempo de ejecución, generación de JSON Schema para tools/list e inferencia de tipos TypeScript desde una única fuente. Es el patrón que uso en cada herramienta MCP que entrego.
Este artículo forma parte del pilar desarrollo de servidores MCP.
TL;DR
- Escribe los esquemas de entrada y salida en Zod y deja que
z.inferderive la firma del handler. - Usa
.strict()para que las claves desconocidas fallen de inmediato en la frontera del protocolo. - Refleja el vocabulario de schema.org en las salidas siempre que exista un tipo equivalente.
- Las herramientas que modifican datos aceptan una
idempotency_keyopcional; memoriza por clave en Workers KV. - Sobre de error a nivel de herramienta para errores de dominio; errores JSON-RPC para fallos de protocolo.
Cómo nombrar las herramientas MCP
Los nombres de las herramientas son el contrato público con cada agente que llegue a llamar a tu servidor. Deben leerse como intenciones de tipo verbo y objeto, no como endpoints REST:
- Bien:
catalogue.list,product.detail,order.intent,inventory.check,order.status. - Mal:
wc_products_get,getProductById,searchCatalog.
Una vez que un agente se ha construido sobre catalogue.list, renombrarla a catalog.list rompe en silencio a todos los consumidores, porque el agente deja de encontrar la herramienta. Trata el nombre como un compromiso de release.
Esquemas de entrada Zod estrictos para herramientas MCP
La configuración por defecto que escribo para cada esquema de entrada:
import { z } from "zod";
export const catalogueListInput = z
.object({
query: z.string().min(2).max(200).optional()
.describe("Búsqueda de texto libre en el nombre del producto y el SKU"),
category: z.string().optional()
.describe("Slug de la categoría tal como aparece en WooCommerce"),
in_stock: z.boolean().optional()
.describe("Limitar a productos disponibles en este momento"),
price_min: z.number().nonnegative().optional()
.describe("Límite inferior del precio normal, inclusive"),
price_max: z.number().nonnegative().optional()
.describe("Límite superior del precio normal, inclusive"),
page: z.number().int().min(1).max(100).default(1)
.describe("Número de página, indexado desde 1"),
per_page: z.number().int().min(1).max(50).default(12)
.describe("Resultados por página, máx. 50"),
})
.strict()
.refine(
(v) => v.price_min === undefined || v.price_max === undefined || v.price_min <= v.price_max,
{ message: "price_min debe ser menor o igual que price_max" }
);Tres cosas a destacar:
.describe() en todas partes. El SDK de MCP propaga la descripción al JSON Schema que devuelve tools/list. El agente la lee. Un campo sin descripción es un campo que el agente tiene que adivinar; un campo con descripción es un campo que el agente acierta.
.strict() en el objeto. Las claves desconocidas fallan en la validación. Un agente que envía { query: "botas", categria: "hombre" } (errata) recibe un error de validación claro que señala categria, en lugar de una llamada correcta que ignora en silencio el filtro mal escrito.
.refine() para reglas entre campos. Todo lo que depende de más de un campo va en .refine(). La restricción del rango de precios es el ejemplo canónico; “solo uno de” es el otro.
Esquema de salida Zod alineado con schema.org Product
El esquema de salida hace dos cosas: documenta la forma de la respuesta para el agente y valida el valor que devuelve el handler antes de que salga del servidor.
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),
});Dos razones para reflejar schema.org directamente:
El mismo vocabulario en todas las superficies. Tu tienda emite JSON-LD de tipo Product en la página de producto. Tu herramienta MCP product.detail devuelve la misma forma. Un agente que combina contexto de “la página que indexé en /tienda/widget” y de “lo que me dijo tu servidor MCP” ve un solo vocabulario, no dos.
Identificadores estables en availability. https://schema.org/InStock es una URL: es duradera, legible por máquinas e inequívoca. Una cadena libre como "sí" o "in stock" es un problema de traducción.
El handler llama a parse sobre la salida:
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrgProduct),
total,
page: input.page,
per_page: input.per_page,
});Si el mapeo de WooCommerce devuelve un Product dañado, parse lanza una excepción que se captura más arriba. El agente nunca ve datos dañados; el desarrollador ve un error de validación tipado en el log.
Claves de idempotencia en herramientas MCP que escriben datos
Las herramientas de lectura son idempotentes por naturaleza. Las herramientas que modifican datos necesitan una clave explícita. El patrón que estableció Stripe es el que sigo (documentación de idempotencia de Stripe):
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("UUID opcional. Las llamadas repetidas con la misma clave devuelven el borrador de pedido original."),
})
.strict();El 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 horas es el TTL por defecto porque basta para que una tormenta de reintentos del agente se calme, y es poco para que el KV siga siendo pequeño.
Errores de protocolo y errores de dominio en MCP
Dos tipos de error, dos transportes:
Errores de protocolo. Method not found, invalid params, parse error. Viajan como respuestas de error JSON-RPC con los códigos -32700 a -32603 (especificación JSON-RPC). El SDK de MCP los gestiona automáticamente cuando falla la validación de entrada en Zod.
Errores de dominio. Sin stock, cliente desconocido, scope insuficiente. Viajan dentro de una respuesta correcta de la herramienta, en un campo error estructurado. El agente los trata como datos, razona sobre ellos y decide qué hacer después.
El sobre que uso:
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,
}),
]);La discriminated union obliga al agente a comprobar status antes de leer cualquier otro campo. Una “respuesta correcta con borrador vacío” nunca pasa la comprobación de tipos: o entregas status: "ok" con los datos del pedido, o status: "error" con el sobre.
Tipos TypeScript a partir de esquemas Zod con z.infer
Los esquemas son la única fuente de verdad. Los tipos salen mediante 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>;La firma del handler los usa directamente:
async function handleCatalogueList(
input: CatalogueListInput,
env: Env,
): Promise<CatalogueListOutput> {
// ...
}Si alguna vez añado un nuevo campo obligatorio a catalogueListInput, cada punto de llamada que no lo aporte falla en la compilación. El compilador de TypeScript impone lo que el validador en tiempo de ejecución ya impone; ambos se apoyan en el mismo esquema Zod.
Cómo versionar los esquemas de las herramientas MCP
Los esquemas evolucionan. La regla que se ha mantenido a lo largo de varias entregas:
Los cambios retrocompatibles son seguros. Un nuevo campo opcional con valor por defecto no rompe los agentes existentes.
Los cambios incompatibles requieren un nuevo nombre de herramienta. Si catalogue.list llega a necesitar un nuevo campo obligatorio o el cambio de nombre de uno existente, publica catalogue.list_v2 en paralelo durante al menos 90 días. Marca catalogue.list como obsoleta en la descripción y elimínala al cerrar la ventana de retirada.
Más sobre servidores MCP para WooCommerce
Este artículo trata de los contratos tipados. La implementación en sí, la estrategia de autenticación, la elección entre MCP y REST y la migración desde una API de WordPress existente son temas aparte. La página del servicio es desarrollo de servidores MCP.
El precio es individual, porque el alcance de los contratos tipados depende de la amplitud de las intenciones de agente que quieras admitir y de la complejidad de las extensiones de WooCommerce que envuelves.







