Typede katalogverktøy med Zod for MCP
En typet kontrakt er forskjellen på en agent som kaller butikken din pålitelig og en agent som hallusinerer parameternavn. Zod (zod.dev) er skjemabiblioteket som den offisielle @modelcontextprotocol/sdk bruker. Det gir validering under kjøring, generering av JSON Schema for tools/list og TypeScript-typeinferens fra én kilde. Dette er mønsteret jeg bruker for hvert MCP-verktøy jeg leverer.
Artikkelen hører til søylesiden utvikling av MCP-servere.
TL;DR
- Skriv inndata- og utdataskjemaer i Zod, og la
z.inferutlede signaturen til handleren. - Bruk
.strict()slik at ukjente nøkler feiler med en gang ved protokollgrensen. - Speil schema.org-vokabularet i utdata når det finnes en tilsvarende type.
- Verktøy som endrer data tar imot en valgfri
idempotency_key; memoiser per nøkkel i Workers KV. - Feilkonvolutt på verktøynivå for domenefeil; JSON-RPC-feil for protokollfeil.
Slik navngir du MCP-verktøy
Verktøynavn er den offentlige kontrakten med hver agent som noen gang kaller serveren din. De skal leses som intensjoner i formen verb og objekt, ikke som REST-endepunkter:
- Bra:
catalogue.list,product.detail,order.intent,inventory.check,order.status. - Dårlig:
wc_products_get,getProductById,searchCatalog.
Når en agent er bygget mot catalogue.list, vil en omdøping til catalog.list stille ødelegge for alle konsumenter, fordi agenten ikke lenger finner verktøyet. Behandle navnet som en release-forpliktelse.
Strenge Zod-inndataskjemaer for MCP-verktøy
Standardoppsettet mitt for hvert inndataskjema:
import { z } from "zod";
export const catalogueListInput = z
.object({
query: z.string().min(2).max(200).optional()
.describe("Fritekstsøk i produktnavn og SKU"),
category: z.string().optional()
.describe("Kategori-slug slik den står i WooCommerce"),
in_stock: z.boolean().optional()
.describe("Begrens til produkter som er på lager nå"),
price_min: z.number().nonnegative().optional()
.describe("Nedre grense for ordinær pris, inkludert"),
price_max: z.number().nonnegative().optional()
.describe("Øvre grense for ordinær pris, inkludert"),
page: z.number().int().min(1).max(100).default(1)
.describe("Sidenummer, indeksert fra 1"),
per_page: z.number().int().min(1).max(50).default(12)
.describe("Resultater per side, maks 50"),
})
.strict()
.refine(
(v) => v.price_min === undefined || v.price_max === undefined || v.price_min <= v.price_max,
{ message: "price_min må være mindre enn eller lik price_max" }
);Tre ting verdt å merke seg:
.describe() overalt. MCP-SDK-et sender beskrivelsen videre til JSON Schema som returneres av tools/list. Agenten leser den. Et felt uten beskrivelse er et felt agenten må gjette seg til; et felt med beskrivelse er et felt agenten treffer riktig.
.strict() på objektet. Ukjente nøkler feiler i valideringen. En agent som sender { query: "støvler", katgori: "herre" } (skrivefeil) får en tydelig valideringsfeil som peker på katgori, i stedet for et vellykket kall som i stillhet ignorerer det feilstavede filteret.
.refine() for regler på tvers av felt. Alt som avhenger av mer enn ett felt hører hjemme i .refine(). Begrensningen på prisintervall er standardeksemplet; “bare ett av” er det andre.
Zod-utdataskjema i tråd med schema.org Product
Utdataskjemaet gjør to ting: det dokumenterer svarets form for agenten, og det validerer verdien handleren returnerer før den forlater serveren.
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),
});To grunner til å speile schema.org direkte:
Samme vokabular på tvers av flater. Butikken din sender ut JSON-LD av typen Product på produktsiden. MCP-verktøyet ditt product.detail returnerer den samme formen. En agent som setter sammen kontekst fra “siden jeg indekserte på /butikk/widget” og “det MCP-serveren din fortalte meg” ser ett vokabular, ikke to.
Stabile identifikatorer i availability. https://schema.org/InStock er en URL; den er varig, maskinlesbar og entydig. En fri streng som "ja" eller "in stock" er et oversettelsesproblem.
Handleren kaller parse på utdata:
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrgProduct),
total,
page: input.page,
per_page: input.per_page,
});Hvis WooCommerce-mappingen returnerer et ødelagt Product, kaster parse et unntak som fanges lenger opp. Agenten ser aldri ødelagte data; utvikleren ser en typet valideringsfeil i loggen.
Idempotensnøkler i MCP-verktøy som skriver data
Leseverktøy er naturlig idempotente. Verktøy som endrer data trenger en eksplisitt nøkkel. Mønsteret Stripe etablerte er det jeg følger (Stripes dokumentasjon om idempotens):
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("Valgfri UUID. Gjentatte kall med samme nøkkel returnerer det opprinnelige ordreutkastet."),
})
.strict();Handleren:
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 timer er standard-TTL fordi det er nok til at en storm av nye forsøk fra agenten legger seg, og lite nok til at KV forblir lite.
Protokollfeil og domenefeil i MCP
To typer feil, to transportveier:
Protokollfeil. Method not found, invalid params, parse error. Disse går som JSON-RPC-feilsvar med kodene -32700 til -32603 (JSON-RPC-spesifikasjonen). MCP-SDK-et håndterer dem automatisk når Zod-valideringen av inndata feiler.
Domenefeil. Ikke på lager, ukjent kunde, utilstrekkelig scope. Disse går inne i et vellykket verktøysvar, i et strukturert error-felt. Agenten behandler dem som data, resonnerer om dem og bestemmer hva som skjer videre.
Konvolutten jeg bruker:
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,
}),
]);En discriminated union tvinger agenten til å sjekke status før den leser noe annet felt. “Vellykket svar med tomt utkast” kommer aldri gjennom typesjekken; enten leverer du status: "ok" med ordredata, eller status: "error" med konvolutten.
TypeScript-typer fra Zod-skjemaer med z.infer
Skjemaene er den eneste sannhetskilden. Typene kommer ut 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>;Signaturen til handleren bruker dem direkte:
async function handleCatalogueList(
input: CatalogueListInput,
env: Env,
): Promise<CatalogueListOutput> {
// ...
}Hvis jeg noen gang legger til et nytt obligatorisk felt i catalogueListInput, feiler hvert kallsted som ikke leverer det ved kompilering. TypeScript-kompilatoren håndhever det valideringen under kjøring allerede håndhever; begge bygger på det samme Zod-skjemaet.
Slik versjonerer du skjemaer for MCP-verktøy
Skjemaer utvikler seg. Regelen som har holdt gjennom flere leveranser:
Bakoverkompatible endringer er trygge. Et nytt valgfritt felt med standardverdi ødelegger ikke eksisterende agenter.
Endringer som bryter kompatibiliteten krever et nytt verktøynavn. Hvis catalogue.list noen gang trenger et nytt obligatorisk felt eller et omdøpt eksisterende felt, publiser catalogue.list_v2 ved siden av i minst 90 dager. Merk catalogue.list som utfaset i beskrivelsen, og fjern det etter utfasingsvinduet.
Mer om MCP-servere for WooCommerce
Denne artikkelen handler om typede kontrakter. Selve implementeringen, autentiseringsstrategien, valget mellom MCP og REST og migreringen fra et eksisterende WordPress-API er egne temaer. Tjenestesiden er utvikling av MCP-servere.
Prisen settes individuelt, fordi omfanget av typede kontrakter avhenger av hvor bredt spekter av agentintensjoner du vil støtte, og av hvor komplekse WooCommerce-utvidelsene du pakker inn er.







