Herramientas de catálogo tipadas con Zod para MCP

Herramientas de catálogo tipadas con Zod para MCP

Última verificación: 22 de septiembre de 2026
8 min de lectura
Guía
500+ proyectos WP
Integración IA

#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.infer derive 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_key opcional; 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.

Siguiente paso

Transforma el artículo en una implementación real

Este bloque refuerza el enlazado interno y lleva al lector al siguiente paso más útil dentro de la arquitectura del sitio.

Cluster relacionado

Explora otros servicios WordPress y base de conocimiento

Refuerza tu negocio con soporte técnico profesional en áreas clave del ecosistema WordPress.

FAQ del artículo

Preguntas frecuentes

Respuestas prácticas para aplicar el tema en la ejecución real.

SEO-readyGEO-readyAEO-ready5 Q&A
¿Por qué Zod y no JSON Schema puro?#
El SDK de TypeScript para MCP acepta esquemas Zod directamente y los convierte a JSON Schema para la respuesta de tools/list. Escribir en Zod aporta inferencia de tipos mediante z.infer, validación en tiempo de ejecución y una única fuente para el SDK y para la firma tipada del handler.
¿De verdad merece la pena validar las salidas?#
Sí. El parse en la salida atrapa errores del handler que, de lo contrario, llegarían al agente como datos mal formados. Un campo renombrado en WooCommerce 9.x o un filtro de plugin roto aparece como un error de validación tipado en el log, y no como un agente confundido que se inventa detalles.
¿Qué tan estricta debe ser la validación de entrada?#
Lo bastante estricta para que el handler nunca reciba basura. Usa .strict() para rechazar claves desconocidas, limita la longitud de las cadenas, acota los rangos numéricos y enumera los conjuntos finitos. Documenta ese rigor en la descripción de la herramienta para que los agentes puedan razonar sobre él.
¿Cómo encajan las claves de idempotencia en el protocolo MCP?#
La clave de idempotencia es solo un campo de entrada opcional en las herramientas que modifican datos. El protocolo no sabe nada de ella. El handler busca la clave en KV, devuelve el resultado anterior si existe y, si no, ejecuta y guarda el resultado en caché.
¿Dónde viven las respuestas de error en MCP?#
MCP admite respuestas de error JSON-RPC para fallos a nivel de protocolo (invalid params, method not found) y sobres de error a nivel de herramienta dentro de respuestas correctas. Uso el sobre de la herramienta para errores de dominio (sin stock, cliente desconocido), para que el agente pueda razonar sobre ellos como datos.

¿Necesitas un FAQ adaptado a tu sector y mercado? Preparamos una versión alineada con tus objetivos de negocio.

Hablemos

Artículos Relacionados