Pisanie typowanych narzędzi katalogu z Zod dla MCP
Typowany kontrakt to różnica między agentem niezawodnie wołającym twój sklep a agentem halucynującym nazwy parametrów. Zod (zod.dev) jest biblioteką schematów, której używa oficjalne @modelcontextprotocol/sdk; daje walidację w runtime, generację JSON Schema dla tools/list i wnioskowanie typów TypeScript z jednego źródła. To wzorzec, którego używam dla każdego narzędzia MCP, jakie wdrażam.
Artykuł osadzony jest w filarze budowa serwera MCP.
TL;DR
- Pisz schematy wejścia i wyjścia w Zod; pozwól
z.inferwyprowadzić sygnaturę handlera. - Używaj
.strict(), by nieznane klucze padały od razu na granicy protokołu. - Lustruj słownik schema.org w wyjściach, jeśli istnieje odpowiadający typ.
- Narzędzia mutujące przyjmują opcjonalny
idempotency_key; memoizuj po kluczu w Workers KV. - Envelope błędu na poziomie narzędzia dla błędów domenowych; błędy JSON-RPC dla awarii protokołu.
Nazewnictwo narzędzia
Nazwy narzędzi to publiczny kontrakt z każdym agentem, który kiedykolwiek zawoła twój serwer. Mają czytać się jak intencje typu czasownik-rzeczownik, nie jak endpointy REST:
- Dobrze:
catalogue.list,product.detail,order.intent,inventory.check,order.status. - Źle:
wc_products_get,getProductById,searchCatalog.
Gdy agent został zbudowany pod catalogue.list, zmiana nazwy na catalog.list rozkłada każdego konsumenta po cichu, bo agent przestaje znajdować narzędzie. Traktuj nazwę jak zobowiązanie release’owe.
Schematy wejścia: ciasne i jawne
Mój default dla każdego schematu wejścia:
import { z } from "zod";
export const catalogueListInput = z
.object({
query: z.string().min(2).max(200).optional()
.describe("Wyszukiwanie pełnotekstowe po nazwie produktu i SKU"),
category: z.string().optional()
.describe("Slug kategorii w formie z WooCommerce"),
in_stock: z.boolean().optional()
.describe("Ogranicz do produktów aktualnie dostępnych"),
price_min: z.number().nonnegative().optional()
.describe("Dolne ograniczenie ceny regularnej, włącznie"),
price_max: z.number().nonnegative().optional()
.describe("Górne ograniczenie ceny regularnej, włącznie"),
page: z.number().int().min(1).max(100).default(1)
.describe("Numer strony, indeksowany od 1"),
per_page: z.number().int().min(1).max(50).default(12)
.describe("Wyniki na stronę, maks. 50"),
})
.strict()
.refine(
(v) => v.price_min === undefined || v.price_max === undefined || v.price_min <= v.price_max,
{ message: "price_min musi być mniejszy lub równy price_max" }
);
Trzy rzeczy do podkreślenia:
.describe() wszędzie. SDK MCP propaguje opis do JSON Schema zwracanego przez tools/list. Agent to czyta. Pole bez opisu to pole, które agent musi zgadywać; pole z opisem to pole, które agent trafia poprawnie.
.strict() na obiekcie. Nieznane klucze padają w walidacji. Agent wysyłający { query: "buty", katgoria: "meskie" } (literówka) dostaje czytelny błąd walidacji wskazujący katgoria, zamiast udanego wywołania, które po cichu ignoruje przekręcony filtr.
.refine() dla reguł międzypolowych. Wszystko, co zależy od więcej niż jednego pola, idzie do .refine(). Ograniczenie zakresu cenowego to przykład kanoniczny; “tylko jedno z” to drugi.
Schematy wyjścia: lustrzane do sklepu
Schemat wyjścia robi dwie rzeczy: dokumentuje kształt odpowiedzi dla agenta i waliduje wartość zwracaną przez handler, zanim opuści ona serwer.
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),
});
Dwa powody, by lustrować schema.org wprost:
Ten sam słownik między powierzchniami. Twój sklep emituje JSON-LD typu Product na stronie produktu. Twoje narzędzie MCP product.detail zwraca ten sam kształt. Agent zszywający kontekst z “strony, którą zaindeksowałem na /sklep/widget” i “tego, co powiedział mi twój serwer MCP” widzi jeden słownik, nie dwa.
Stabilne identyfikatory w availability. https://schema.org/InStock to URL; jest trwały, czytelny maszynowo i jednoznaczny. Wolny string typu "tak" lub "in stock" to problem tłumaczeniowy.
Handler woła parse na wyjściu:
return catalogueListOutput.parse({
results: products.map(mapWooProductToSchemaOrgProduct),
total,
page: input.page,
per_page: input.per_page,
});
Jeśli mapowanie WooCommerce zwróci uszkodzony Product, parse rzuca wyjątek i błąd jest łapany wyżej. Agent nigdy nie zobaczy uszkodzonych danych; deweloper zobaczy typowany błąd walidacji w logu.
Klucze idempotencji dla narzędzi mutujących
Narzędzia odczytu są naturalnie idempotentne. Narzędzia mutujące potrzebują jawnego klucza. Wzorzec ustanowiony przez Stripe jest tym, którego się trzymam (dokumentacja idempotencji 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("Opcjonalny UUID. Powtórzone wywołania z tym samym kluczem zwracają oryginalny szkic zamówienia."),
})
.strict();
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 godziny to default TTL, bo to dość, by burza retry agenta opadła, i mało, by KV pozostało małe.
Envelope błędu
Dwa rodzaje błędów, dwa transporty:
Błędy protokołu. Method not found, invalid params, parse error. Te jadą jako odpowiedzi błędów JSON-RPC z kodami -32700 do -32603 (specyfikacja JSON-RPC). SDK MCP obsługuje je automatycznie, gdy walidacja wejścia w Zod padnie.
Błędy domenowe. Brak na stanie, klient nieznany, niewystarczający scope. Te jadą wewnątrz udanej odpowiedzi narzędzia, w strukturalnym polu error. Agent traktuje je jako dane, rozumuje o nich i decyduje co dalej.
Envelope, którego używam:
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,
}),
]);
Discriminated union zmusza agenta do sprawdzenia status, zanim odczyta jakiekolwiek inne pole. “Udana odpowiedź z pustym szkicem” nigdy nie przechodzi typecheck’u; albo dostarczasz status: "ok" z danymi zamówienia, albo status: "error" z envelope’em.
Wnioskowanie typów ze schematów
Schematy są jedynym źródłem prawdy. Typy wychodzą przez 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>;
Sygnatura handlera używa ich wprost:
async function handleCatalogueList(
input: CatalogueListInput,
env: Env,
): Promise<CatalogueListOutput> {
// ...
}
Jeśli kiedykolwiek dorzucę nowe pole obowiązkowe do catalogueListInput, każde miejsce wywołania, które go nie dostarcza, padnie przy kompilacji. Kompilator TypeScript egzekwuje to, co już egzekwuje walidator runtime; oba siedzą na tym samym schemacie Zod.
Wersjonowanie schematów
Schematy ewoluują. Reguła, która utrzymuje się przez kilka wdrożeń:
Zmiany kompatybilne wstecz są bezpieczne. Nowe pole opcjonalne z wartością domyślną nie psuje istniejących agentów.
Zmiany niekompatybilne wymagają nowej nazwy narzędzia. Jeśli catalogue.list kiedykolwiek potrzebuje nowego pola obowiązkowego lub przemianowania istniejącego, wystaw catalogue.list_v2 obok przez co najmniej 90 dni. Oznacz catalogue.list jako deprekowany w opisie; usuń po oknie deprekacji.
Gdzie to siedzi w klastrze
Ten artykuł pokrywa typowane kontrakty. Po przebieg implementacji sięgnij do budowy serwera MCP dla WooCommerce. Po strategię auth do wzorców uwierzytelniania MCP. Po decyzję na poziomie protokołu do MCP vs REST. Po ścieżkę migracji z istniejącego API do migracji API WordPress do MCP. Filar to budowa serwera MCP.
Wycena indywidualna, bo zakres typowanych kontraktów zależy od szerokości intencji agenta, które chcesz wspierać, i od złożoności rozszerzeń WooCommerce, które opakowujesz.
