Ferramentas de catálogo tipadas com Zod para MCP
Um contrato tipado é a diferença entre um agente que chama a sua loja de forma fiável e um agente que alucina nomes de parâmetros. O Zod (zod.dev) é a biblioteca de esquemas usada pelo @modelcontextprotocol/sdk oficial: dá validação em tempo de execução, geração de JSON Schema para tools/list e inferência de tipos TypeScript a partir de uma única fonte. É o padrão que uso em todas as ferramentas MCP que entrego.
Este artigo está ligado ao pilar desenvolvimento de servidores MCP.
TL;DR
- Escreva os esquemas de entrada e saída em Zod e deixe que
z.inferderive a assinatura do handler. - Use
.strict()para que as chaves desconhecidas falhem logo na fronteira do protocolo. - Espelhe o vocabulário schema.org nas saídas sempre que exista um tipo correspondente.
- As ferramentas que alteram dados aceitam uma
idempotency_keyopcional; memorize por chave em Workers KV. - Envelope de erro ao nível da ferramenta para erros de domínio; erros JSON-RPC para falhas de protocolo.
Como dar nome às ferramentas MCP
Os nomes das ferramentas são o contrato público com cada agente que alguma vez chame o seu servidor. Devem ler-se como intenções do tipo verbo e objeto, e não como endpoints REST:
- Bem:
catalogue.list,product.detail,order.intent,inventory.check,order.status. - Mal:
wc_products_get,getProductById,searchCatalog.
Depois de um agente ter sido construído com base em catalogue.list, mudar o nome para catalog.list parte todos os consumidores em silêncio, porque o agente deixa de encontrar a ferramenta. Trate o nome como um compromisso de release.
Esquemas de entrada Zod rigorosos para ferramentas MCP
A configuração por omissão que escrevo para cada esquema de entrada:
import { z } from "zod";
export const catalogueListInput = z
.object({
query: z.string().min(2).max(200).optional()
.describe("Pesquisa de texto livre no nome do produto e no SKU"),
category: z.string().optional()
.describe("Slug da categoria tal como aparece no WooCommerce"),
in_stock: z.boolean().optional()
.describe("Limitar a produtos atualmente em stock"),
price_min: z.number().nonnegative().optional()
.describe("Limite inferior do preço normal, inclusive"),
price_max: z.number().nonnegative().optional()
.describe("Limite superior do preço normal, inclusive"),
page: z.number().int().min(1).max(100).default(1)
.describe("Número da página, indexado a partir de 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 tem de ser menor ou igual a price_max" }
);Três pontos a destacar:
.describe() em todo o lado. O SDK do MCP propaga a descrição para o JSON Schema devolvido por tools/list. O agente lê-a. Um campo sem descrição é um campo que o agente tem de adivinhar; um campo com descrição é um campo que o agente acerta.
.strict() no objeto. As chaves desconhecidas falham na validação. Um agente que envie { query: "botas", categria: "homem" } (gralha) recebe um erro de validação claro a apontar para categria, em vez de uma chamada bem-sucedida que ignora em silêncio o filtro mal escrito.
.refine() para regras entre campos. Tudo o que depende de mais de um campo vai para .refine(). A restrição do intervalo de preços é o exemplo canónico; “apenas um de” é o outro.
Esquema de saída Zod alinhado com schema.org Product
O esquema de saída faz duas coisas: documenta a forma da resposta para o agente e valida o valor devolvido pelo handler antes de este sair do 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),
});Duas razões para espelhar o schema.org diretamente:
O mesmo vocabulário em todas as superfícies. A sua loja emite JSON-LD do tipo Product na página de produto. A sua ferramenta MCP product.detail devolve a mesma forma. Um agente que junta contexto de “a página que indexei em /loja/widget” e de “o que o seu servidor MCP me disse” vê um só vocabulário, e não dois.
Identificadores estáveis em availability. https://schema.org/InStock é um URL: é duradouro, legível por máquinas e inequívoco. Uma string livre como "sim" ou "in stock" é um problema de tradução.
O handler chama parse sobre a saída:
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrgProduct),
total,
page: input.page,
per_page: input.per_page,
});Se o mapeamento do WooCommerce devolver um Product corrompido, o parse lança uma exceção que é apanhada mais acima. O agente nunca vê dados corrompidos; o programador vê um erro de validação tipado no registo.
Chaves de idempotência em ferramentas MCP que escrevem dados
As ferramentas de leitura são naturalmente idempotentes. As ferramentas que alteram dados precisam de uma chave explícita. O padrão estabelecido pela Stripe é o que sigo (documentação de idempotência da 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. Chamadas repetidas com a mesma chave devolvem o rascunho de encomenda original."),
})
.strict();O 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 é o TTL por omissão porque chega para que uma tempestade de novas tentativas do agente acalme, e é pouco o suficiente para que o KV se mantenha pequeno.
Erros de protocolo e erros de domínio no MCP
Dois tipos de erro, dois transportes:
Erros de protocolo. Method not found, invalid params, parse error. Estes seguem como respostas de erro JSON-RPC com os códigos -32700 a -32603 (especificação JSON-RPC). O SDK do MCP trata-os automaticamente quando a validação da entrada em Zod falha.
Erros de domínio. Sem stock, cliente desconhecido, scope insuficiente. Estes seguem dentro de uma resposta bem-sucedida da ferramenta, num campo error estruturado. O agente trata-os como dados, raciocina sobre eles e decide o passo seguinte.
O envelope 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,
}),
]);A discriminated union obriga o agente a verificar status antes de ler qualquer outro campo. Uma “resposta bem-sucedida com rascunho vazio” nunca passa a verificação de tipos: ou entrega status: "ok" com os dados da encomenda, ou status: "error" com o envelope.
Tipos TypeScript a partir de esquemas Zod com z.infer
Os esquemas são a única fonte de verdade. Os tipos saem através de 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>;A assinatura do handler usa-os diretamente:
async function handleCatalogueList(
input: CatalogueListInput,
env: Env,
): Promise<CatalogueListOutput> {
// ...
}Se alguma vez eu acrescentar um novo campo obrigatório a catalogueListInput, todos os pontos de chamada que não o forneçam falham na compilação. O compilador de TypeScript impõe o que o validador em tempo de execução já impõe; ambos assentam no mesmo esquema Zod.
Como versionar os esquemas das ferramentas MCP
Os esquemas evoluem. A regra que se tem mantido ao longo de várias entregas:
As alterações retrocompatíveis são seguras. Um novo campo opcional com valor por omissão não parte os agentes existentes.
As alterações incompatíveis exigem um novo nome de ferramenta. Se catalogue.list precisar algum dia de um novo campo obrigatório ou de renomear um campo existente, publique catalogue.list_v2 em paralelo durante pelo menos 90 dias. Marque catalogue.list como obsoleta na descrição e remova-a depois da janela de descontinuação.
Mais sobre servidores MCP para WooCommerce
Este artigo trata dos contratos tipados. A implementação em si, a estratégia de autenticação, a escolha entre MCP e REST e a migração a partir de uma API WordPress existente são temas à parte. A página do serviço é desenvolvimento de servidores MCP.
O preço é definido caso a caso, porque o âmbito dos contratos tipados depende da amplitude das intenções de agente que pretende suportar e da complexidade das extensões WooCommerce que está a envolver.







