Typisierte Katalog-Tools mit Zod für MCP
Ein typisierter Vertrag ist der Unterschied zwischen einem Agenten, der zuverlässig deinen Shop aufruft, und einem, der Parameternamen halluziniert. Zod (zod.dev) ist die Schema-Bibliothek, die das offizielle @modelcontextprotocol/sdk nutzt; sie liefert Laufzeit-Validierung, JSON-Schema-Generierung für tools/list und TypeScript-Typinferenz aus einer einzigen Quelle. Genau dieses Muster nutze ich für jedes MCP-Tool, das ich ausliefere.
Dieser Artikel verankert sich am Pillar MCP-Server-Entwicklungsservice.
TL;DR
- Schreibe Input- und Output-Schemata in Zod; lass
z.inferdie Handler-Signatur ableiten. - Nutze
.strict(), sodass unbekannte Schlüssel früh an der Protokollgrenze scheitern. - Spiegele schema.org-Vokabular in Outputs, wo ein passender Typ existiert.
- Mutierende Tools nehmen einen optionalen
idempotency_key; memoisiere per Schlüssel in Workers KV. - Tool-Level-Fehler-Envelope für Domänenfehler; JSON-RPC-Fehler für Protokoll-Fehler.
Tool benennen
Tool-Namen sind der öffentliche Vertrag mit jedem Agenten, der je deinen Server aufruft. Sie sollen sich wie Verb-Objekt-Intents lesen, nicht wie REST-Endpunkte:
- Gut:
catalogue.list,product.detail,order.intent,inventory.check,order.status. - Schlecht:
wc_products_get,getProductById,searchCatalog.
Sobald ein Agent gegen catalogue.list gebaut wurde, bricht eine Umbenennung in catalog.list jeden Konsumenten still, weil der Agent das Tool nicht mehr findet. Behandle den Namen wie ein Release-Versprechen.
Input-Schemata: eng und explizit
Mein Default für jedes Input-Schema:
import { z } from "zod";
export const catalogueListInput = z
.object({
query: z.string().min(2).max(200).optional()
.describe("Freitextsuche über Produktname und SKU"),
category: z.string().optional()
.describe("Kategorien-Slug, wie er in WooCommerce erscheint"),
in_stock: z.boolean().optional()
.describe("Auf aktuell vorrätige Produkte einschränken"),
price_min: z.number().nonnegative().optional()
.describe("Untere Grenze des regulären Preises, inklusive"),
price_max: z.number().nonnegative().optional()
.describe("Obere Grenze des regulären Preises, inklusive"),
page: z.number().int().min(1).max(100).default(1)
.describe("Seitennummer, 1-basiert"),
per_page: z.number().int().min(1).max(50).default(12)
.describe("Ergebnisse pro Seite, max 50"),
})
.strict()
.refine(
(v) => v.price_min === undefined || v.price_max === undefined || v.price_min <= v.price_max,
{ message: "price_min muss kleiner oder gleich price_max sein" }
);
Drei Dinge, die hervorzuheben sind:
.describe() überall. Das MCP-SDK leitet die Description in das JSON Schema weiter, das tools/list zurückgibt. Der Agent liest sie. Ein Feld ohne Description ist ein Feld, das der Agent raten muss; ein Feld mit Description ist ein Feld, das der Agent richtig trifft.
.strict() am Objekt. Unbekannte Schlüssel scheitern in der Validierung. Ein Agent, der { query: "stiefel", katgorie: "herren" } (Tippfehler) sendet, bekommt einen klaren Validierungsfehler auf katgorie, statt eines erfolgreichen Aufrufs, der den verschriebenen Filter still ignoriert.
.refine() für feldübergreifende Regeln. Alles, was von mehr als einem Feld abhängt, gehört in ein .refine(). Die Preis-Range-Bedingung ist das kanonische Beispiel; “nur eines von” ist das andere.
Output-Schemata: am Storefront ausrichten
Das Output-Schema erledigt zwei Aufgaben: es dokumentiert die Antwortform für den Agenten und es validiert den Rückgabewert des Handlers, bevor er den Server verlässt.
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),
});
Zwei Gründe, schema.org explizit zu spiegeln:
Dasselbe Vokabular über Oberflächen hinweg. Dein Storefront emittiert Product-JSON-LD auf der Produktdetailseite. Dein MCP-product.detail-Tool liefert dieselbe Form. Ein Agent, der Kontext aus “der Seite, die ich auf /shop/widget gecrawlt habe” und “was dein MCP-Server mir sagte” zusammennäht, sieht ein Vokabular, nicht zwei.
Stabile IDs in availability. https://schema.org/InStock ist eine URL; sie ist permanent, maschinenlesbar und eindeutig. Ein freier String wie "ja" oder "in stock" ist ein Übersetzungsproblem.
Der Handler ruft parse beim Verlassen auf:
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrgProduct),
total,
page: input.page,
per_page: input.per_page,
});
Liefert das WooCommerce-Mapping ein malformes Product, wirft parse und der Fehler wird stromaufwärts gefangen. Der Agent sieht die malformen Daten nie; der Entwickler sieht einen typisierten Validierungsfehler im Log.
Idempotenz-Schlüssel für mutierende Tools
Lese-Tools sind natürlich idempotent. Mutierende Tools brauchen einen expliziten Schlüssel. Das Muster, das Stripe etabliert hat, ist das, dem ich folge (Stripe-Idempotenz-Referenz):
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("Optionale UUID. Wiederholte Aufrufe mit demselben Schlüssel liefern denselben Bestellentwurf."),
})
.strict();
Der 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 Stunden ist die Default-TTL, weil das lang genug für einen Agenten-Retry-Sturm ist und kurz genug, dass KV klein bleibt.
Das Fehler-Envelope
Zwei Fehlerarten, zwei Transporte:
Protokoll-Fehler. Method not found, invalid params, parse error. Diese reisen als JSON-RPC-Fehlerantworten mit Codes -32700 bis -32603 (JSON-RPC-Spec). Das MCP-SDK behandelt sie automatisch, wenn die Zod-Input-Validierung scheitert.
Domänenfehler. Nicht auf Lager, Kunde nicht gefunden, Scope unzureichend. Diese reisen innerhalb einer erfolgreichen Tool-Antwort, in einem strukturierten error-Feld. Der Agent behandelt sie als Daten, räsoniert darüber und entscheidet als Nächstes.
Das Envelope, das ich nutze:
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,
}),
]);
Die Discriminated Union zwingt den Agenten, status zu prüfen, bevor er irgendein anderes Feld liest. Eine “erfolgreiche Antwort mit leerem Entwurf” geht den Typcheck nie durch; entweder lieferst du status: "ok" mit den Bestelldaten oder status: "error" mit dem Envelope.
Typinferenz aus den Schemata
Schemata sind die einzige Wahrheit. Typen kommen 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>;
Die Handler-Signatur nutzt diese direkt:
async function handleCatalogueList(
input: CatalogueListInput,
env: Env,
): Promise<CatalogueListOutput> {
// ...
}
Wenn ich catalogueListInput jemals um ein neues Pflichtfeld erweitere, scheitert jede Aufrufstelle, die es nicht liefert, beim Compile. Der TypeScript-Compiler erzwingt, was der Laufzeit-Validator schon erzwingt; beide hängen am selben Zod-Schema.
Schemata versionieren
Schemata entwickeln sich. Die Regel, die sich über mehrere Deployments gehalten hat:
Rückwärtskompatible Erweiterungen sind sicher. Ein neues optionales Feld mit Default-Wert bricht keinen bestehenden Agenten.
Rückwärtsinkompatible Änderungen verlangen einen neuen Tool-Namen. Wenn catalogue.list jemals ein neues Pflichtfeld oder ein umbenanntes existierendes Feld braucht, liefere catalogue.list_v2 mindestens 90 Tage daneben. Markiere catalogue.list in der Description als deprekiert; entferne es nach dem Deprekierungsfenster.
Wo das im Cluster sitzt
Dieser Artikel deckt typisierte Verträge ab. Für den Implementierungs-Durchstich siehe MCP-Server für WooCommerce aufbauen. Für die Auth-Strategie siehe MCP-Authentifizierungsmuster. Für die Protokoll-Entscheidung siehe MCP vs REST. Für den Migrationspfad aus einer bestehenden API siehe bestehende WordPress-API auf MCP migrieren. Der Pillar ist MCP-Server-Entwicklung.
Preisgestaltung individuell, weil der Umfang typisierter Verträge davon abhängt, wie breit die Agent-Intents sind, die du unterstützen willst, und wie komplex die WooCommerce-Erweiterungen sind, die du kapselst.
