/**
* Zod input validation for server tool dispatch.
*
* Only mutation actions are validated — reads (find, get, list, summary, etc.)
* pass through without schema checks. This prevents malformed AI tool calls
* from crashing handlers with TypeErrors on unchecked `as` casts.
*/
import { z } from "zod";
// ============================================================================
// SHARED PRIMITIVES
// ============================================================================
const uuid = z.string().uuid();
const positiveNumber = z.number().positive();
const nonNegativeNumber = z.number().min(0);
// ============================================================================
// INVENTORY MUTATIONS
// ============================================================================
const inventoryAdjustSchema = z.object({
action: z.literal("adjust"),
product_id: uuid,
location_id: uuid,
adjustment: z.number(),
reason: z.string().optional(),
});
const inventorySetSchema = z.object({
action: z.literal("set"),
product_id: uuid,
location_id: uuid,
quantity: nonNegativeNumber,
});
const inventoryTransferSchema = z.object({
action: z.literal("transfer"),
product_id: uuid,
from_location_id: uuid,
to_location_id: uuid,
quantity: positiveNumber,
});
const inventoryBulkSetSchema = z.object({
action: z.literal("bulk_set"),
items: z.array(z.object({
product_id: uuid,
location_id: uuid,
quantity: nonNegativeNumber,
})).min(1),
});
const inventoryBulkAdjustSchema = z.object({
action: z.literal("bulk_adjust"),
items: z.array(z.object({
product_id: uuid,
location_id: uuid,
adjustment: z.number(),
})).min(1),
});
const inventoryBulkClearSchema = z.object({
action: z.literal("bulk_clear"),
location_id: uuid,
});
// ============================================================================
// SUPPLY CHAIN — PURCHASE ORDERS
// ============================================================================
const poCreateSchema = z.object({
action: z.literal("create"),
supplier_id: uuid,
location_id: uuid,
items: z.array(z.object({
product_id: uuid,
quantity: positiveNumber,
unit_price: nonNegativeNumber.optional(),
unit_cost: nonNegativeNumber.optional(),
})).min(1).optional(),
notes: z.string().optional(),
expected_delivery_date: z.string().optional(),
po_type: z.string().optional(),
});
const poAddItemsSchema = z.object({
action: z.literal("add_items"),
purchase_order_id: uuid,
items: z.array(z.object({
product_id: uuid,
quantity: positiveNumber,
unit_price: nonNegativeNumber.optional(),
unit_cost: nonNegativeNumber.optional(),
})).min(1),
});
const poApproveSchema = z.object({
action: z.literal("approve"),
purchase_order_id: uuid,
});
const poReceiveSchema = z.object({
action: z.literal("receive"),
purchase_order_id: uuid,
});
const poCancelSchema = z.object({
action: z.literal("cancel"),
purchase_order_id: uuid,
});
// ============================================================================
// SUPPLY CHAIN — TRANSFERS
// ============================================================================
const transferCreateSchema = z.object({
action: z.literal("create"),
from_location_id: uuid.optional(),
source_location_id: uuid.optional(),
to_location_id: uuid.optional(),
destination_location_id: uuid.optional(),
items: z.array(z.object({
product_id: uuid,
quantity: positiveNumber,
})).min(1).optional(),
notes: z.string().optional(),
}).refine(
(d) => d.from_location_id || d.source_location_id,
{ message: "from_location_id or source_location_id is required", path: ["from_location_id"] }
).refine(
(d) => d.to_location_id || d.destination_location_id,
{ message: "to_location_id or destination_location_id is required", path: ["to_location_id"] }
);
const transferReceiveSchema = z.object({
action: z.literal("receive"),
transfer_id: uuid,
});
const transferCancelSchema = z.object({
action: z.literal("cancel"),
transfer_id: uuid,
});
// ============================================================================
// CUSTOMERS
// ============================================================================
const customerCreateSchema = z.object({
action: z.literal("create"),
first_name: z.string().min(1),
last_name: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
date_of_birth: z.string().optional(),
email_consent: z.boolean().optional(),
sms_consent: z.boolean().optional(),
street_address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postal_code: z.string().optional(),
drivers_license_number: z.string().optional(),
medical_card_number: z.string().optional(),
medical_card_expiry: z.string().optional(),
});
const customerUpdateSchema = z.object({
action: z.literal("update"),
customer_id: uuid,
first_name: z.string().min(1).optional(),
last_name: z.string().min(1).optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
date_of_birth: z.string().optional(),
status: z.string().optional(),
email_consent: z.boolean().optional(),
sms_consent: z.boolean().optional(),
push_consent: z.boolean().optional(),
loyalty_points: z.number().optional(),
loyalty_tier: z.string().optional(),
street_address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postal_code: z.string().optional(),
drivers_license_number: z.string().optional(),
id_verified: z.boolean().optional(),
medical_card_number: z.string().optional(),
medical_card_expiry: z.string().optional(),
is_wholesale_approved: z.boolean().optional(),
wholesale_tier: z.string().optional(),
wholesale_business_name: z.string().optional(),
wholesale_license_number: z.string().optional(),
wholesale_tax_id: z.string().optional(),
});
const customerMergeSchema = z.object({
action: z.literal("merge"),
primary_customer_id: uuid,
secondary_customer_id: uuid,
});
// ============================================================================
// SCHEMA REGISTRY — maps (toolName, action) → Zod schema
// ============================================================================
type SchemaMap = Record<string, z.ZodTypeAny>;
const inventorySchemas: SchemaMap = {
adjust: inventoryAdjustSchema,
set: inventorySetSchema,
transfer: inventoryTransferSchema,
bulk_set: inventoryBulkSetSchema,
bulk_adjust: inventoryBulkAdjustSchema,
bulk_clear: inventoryBulkClearSchema,
};
const purchaseOrderSchemas: SchemaMap = {
create: poCreateSchema,
add_items: poAddItemsSchema,
approve: poApproveSchema,
receive: poReceiveSchema,
cancel: poCancelSchema,
};
const transferSchemas: SchemaMap = {
create: transferCreateSchema,
receive: transferReceiveSchema,
cancel: transferCancelSchema,
};
const customerSchemas: SchemaMap = {
create: customerCreateSchema,
update: customerUpdateSchema,
merge: customerMergeSchema,
};
/**
* Top-level registry keyed by tool name.
* Only tools with mutation actions are listed.
* Read-only tools (analytics, orders.find, etc.) are intentionally absent — passthrough.
*/
const toolSchemaRegistry: Record<string, SchemaMap> = {
inventory: inventorySchemas,
purchase_orders: purchaseOrderSchemas,
transfers: transferSchemas,
customers: customerSchemas,
};
// ============================================================================
// SUPPLY_CHAIN META-TOOL RESOLUTION
// ============================================================================
/**
* The "supply_chain" tool dispatches by action prefix:
* po_create → purchase_orders / create
* transfer_create → transfers / create
*
* This helper resolves supply_chain actions to the underlying tool + action.
*/
function resolveSupplyChainAction(action: string): { tool: string; action: string } | null {
if (action.startsWith("po_")) return { tool: "purchase_orders", action: action.slice(3) };
if (action.startsWith("transfer_")) return { tool: "transfers", action: action.slice(9) };
return null;
}
// ============================================================================
// PUBLIC API
// ============================================================================
export type ValidationResult =
| { valid: true; data: Record<string, unknown> }
| { valid: false; error: string };
/**
* Validate tool args before dispatch to handler.
*
* Returns `{ valid: true, data }` with parsed (coerced) data on success,
* or `{ valid: false, error }` with a human-readable message on failure.
*
* If no schema is registered for the (tool, action) pair, returns passthrough
* with `{ valid: true, data: args }` — no validation, no blocking.
*/
export function validateToolArgs(
toolName: string,
args: Record<string, unknown>,
): ValidationResult {
const action = args.action as string | undefined;
if (!action) {
// No action param = passthrough (some tools don't use action)
return { valid: true, data: args };
}
let schemaMap: SchemaMap | undefined;
if (toolName === "supply_chain") {
const resolved = resolveSupplyChainAction(action);
if (resolved) {
schemaMap = toolSchemaRegistry[resolved.tool];
// Look up by the resolved action (e.g. "create" not "po_create")
const schema = schemaMap?.[resolved.action];
if (!schema) return { valid: true, data: args };
// For supply_chain, we validate using the inner action name
// Rebuild args with the inner action for schema parsing
const innerArgs = { ...args, action: resolved.action };
return runSchema(schema, innerArgs, args);
}
// Unknown supply_chain action (e.g. find_suppliers) — passthrough
return { valid: true, data: args };
}
schemaMap = toolSchemaRegistry[toolName];
if (!schemaMap) return { valid: true, data: args };
const schema = schemaMap[action];
if (!schema) return { valid: true, data: args };
return runSchema(schema, args, args);
}
/**
* Parse args against schema. On success, merge parsed data back onto original
* args so extra fields (not in schema) are preserved for the handler.
*/
function runSchema(
schema: z.ZodTypeAny,
parseTarget: Record<string, unknown>,
originalArgs: Record<string, unknown>,
): ValidationResult {
const result = schema.safeParse(parseTarget);
if (!result.success) {
const issues = result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
);
return { valid: false, error: `Validation failed: ${issues.join(", ")}` };
}
// Merge parsed data back with original args so extra passthrough fields survive
const parsed = result.data as Record<string, unknown>;
return { valid: true, data: { ...originalArgs, ...parsed } };
}