Files
homaroma/apps/mcp-server/storefront-toolkit.ts
T
2026-10-11 06:43:08 +07:00

140 lines
13 KiB
TypeScript

import type { AxiosInstance } from "axios";
import { CATEGORY_SECTION_TYPES, FooterLayoutSettingsPatchSchema } from "@homaroma/types";
type ToolResult = { content: [{ type: "text"; text: string }]; isError?: true };
const result = (value: unknown, isError = false): ToolResult => ({
content: [{ type: "text", text: JSON.stringify(value) }],
...(isError ? { isError: true as const } : {}),
});
const failure = (code: string, message: string, details?: unknown) => result({ success: false, code, message, ...(details === undefined ? {} : { details }) }, true);
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
const positiveId = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
function apiError(error: unknown): ToolResult {
const response = (error as any)?.response;
const data = response?.data;
return failure(
data?.code || (response?.status === 409 ? "CONFLICT" : response?.status ? "BACKEND_ERROR" : "NETWORK_ERROR"),
data?.error || data?.message || (error as Error)?.message || "Request failed",
{ status: response?.status, issues: data?.issues, currentVersion: data?.currentVersion },
);
}
const footerPatchProperties = {
columns: { type: "array", items: { type: "string", enum: ["brand", "explore", "collections", "support"] }, minItems: 1, maxItems: 4, uniqueItems: true },
mobileColumns: { type: "integer", enum: [1, 2] },
desktopColumns: { type: "integer", enum: [2, 3, 4] },
containerWidth: { type: "string", enum: ["narrow", "standard", "wide", "full"] },
verticalPadding: { type: "string", enum: ["sm", "md", "lg", "xl"] },
columnGap: { type: "string", enum: ["sm", "md", "lg", "xl"] },
headingSize: { type: "string", enum: ["sm", "md", "lg"] },
linkSize: { type: "string", enum: ["sm", "md", "lg"] },
brandLogoSize: { type: "string", enum: ["sm", "md", "lg", "xl"] },
socialSize: { type: "string", enum: ["sm", "md", "lg"] },
finale: { type: "object", additionalProperties: false, properties: {
enabled: { type: "boolean" }, mode: { type: "string", enum: ["static", "curtain"] },
mobileHeight: { type: "string", enum: ["compact", "medium", "large"] },
desktopHeight: { type: "string", enum: ["compact", "half", "screen"] },
logoSize: { type: "string", enum: ["sm", "md", "lg", "xl"] },
} },
} as const;
const idSchema = { type: "integer", minimum: 1 } as const;
const sectionFields = {
type: { type: "string", enum: [...CATEGORY_SECTION_TYPES] },
payload: { type: "object", description: "Complete payload for its type; backend validates it. For update, omitted payload preserves the current value." },
order: { type: "number" }, isActive: { type: "boolean" }, isLocked: { type: "boolean" },
dryRun: { type: "boolean", description: "Preview only. Category backend has no atomic version check; preview may become stale." },
} as const;
export const STOREFRONT_TOOL_DEFINITIONS = [
{ name: "get_footer_layout", description: "Read footer layout and version. Pass that version to patch_footer_layout before writing.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
{ name: "patch_footer_layout", description: "Patch footer layout. Use dryRun=true to preview. An actual write requires expectedVersion from get_footer_layout; backend returns VERSION_CONFLICT on concurrent changes.", inputSchema: { type: "object", properties: { ...footerPatchProperties, expectedVersion: { type: "string" }, dryRun: { type: "boolean" } }, additionalProperties: false } },
{ name: "get_category_sections", description: "Read category sections, including inactive sections by default for editing.", inputSchema: { type: "object", properties: { categoryId: idSchema, includeInactive: { type: "boolean" }, lang: { type: "string", description: "Locale; defaults to th." } }, required: ["categoryId"], additionalProperties: false } },
{ name: "seed_category_sections", description: "Seed three default sections only when category has none. Returns 409 if sections already exist. dryRun previews without writing.", inputSchema: { type: "object", properties: { categoryId: idSchema, dryRun: { type: "boolean" } }, required: ["categoryId"], additionalProperties: false } },
{ name: "add_category_section", description: "Create section. On an empty category, backend also seeds defaults; same-type default may be updated instead of creating a fourth section. Preview first to inspect this behavior.", inputSchema: { type: "object", properties: { categoryId: idSchema, ...sectionFields }, required: ["categoryId", "type", "payload"], additionalProperties: false } },
{ name: "update_category_section", description: "Update one section. Supply categoryId to verify ownership; locked sections cannot change type, be hidden or unlocked. Payload, when supplied, replaces stored payload.", inputSchema: { type: "object", properties: { categoryId: idSchema, sectionId: idSchema, ...sectionFields }, required: ["categoryId", "sectionId"], additionalProperties: false } },
{ name: "reorder_category_sections", description: "Reorder all category sections. Supply every current section ID exactly once; preview before write. Backend has no atomic version check.", inputSchema: { type: "object", properties: { categoryId: idSchema, sections: { type: "array", items: { type: "object", properties: { id: idSchema, order: { type: "number" } }, required: ["id", "order"], additionalProperties: false } }, dryRun: { type: "boolean" } }, required: ["categoryId", "sections"], additionalProperties: false } },
{ name: "delete_category_section", description: "Delete an unlocked section after verifying category ownership. dryRun previews without writing.", inputSchema: { type: "object", properties: { categoryId: idSchema, sectionId: idSchema, dryRun: { type: "boolean" } }, required: ["categoryId", "sectionId"], additionalProperties: false } },
] as const;
const toolNames = new Set<string>(STOREFRONT_TOOL_DEFINITIONS.map((tool) => tool.name));
const categoryPath = (id: number) => `/categories/${id}/sections`;
async function sectionsForEdit(api: AxiosInstance, categoryId: number): Promise<any[]> {
const response = await api.get(categoryPath(categoryId), { params: { includeInactive: true, lang: "th" } });
if (!Array.isArray(response.data)) throw new Error("Backend returned an invalid category sections list");
return response.data;
}
export async function handleStorefrontTool(api: AxiosInstance, name: string, rawParams: unknown): Promise<ToolResult | null> {
if (!toolNames.has(name)) return null;
const params = object(rawParams) ? rawParams : {};
try {
if (name === "get_footer_layout") return result((await api.get("/settings/footer-layout")).data);
if (name === "patch_footer_layout") {
const { expectedVersion, dryRun, ...patch } = params;
const parsed = FooterLayoutSettingsPatchSchema.safeParse(patch);
if (!parsed.success) return failure("INVALID_INPUT", "Invalid footer layout patch", parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })));
if (!Object.keys(parsed.data).length) return failure("INVALID_INPUT", "Provide at least one footer layout field");
if (dryRun !== undefined && typeof dryRun !== "boolean") return failure("INVALID_INPUT", "dryRun must be boolean");
if (!dryRun && (typeof expectedVersion !== "string" || !expectedVersion)) return failure("VERSION_REQUIRED", "Read get_footer_layout and pass its version as expectedVersion");
if (expectedVersion !== undefined && (typeof expectedVersion !== "string" || !expectedVersion)) return failure("INVALID_INPUT", "expectedVersion must be a non-empty string");
return result((await api.patch("/settings/footer-layout", { ...parsed.data, ...(expectedVersion ? { expectedVersion } : {}), ...(dryRun ? { dryRun: true } : {}) })).data);
}
if (!positiveId(params.categoryId)) return failure("INVALID_INPUT", "categoryId must be a positive integer");
const categoryId = params.categoryId;
if (params.dryRun !== undefined && typeof params.dryRun !== "boolean") return failure("INVALID_INPUT", "dryRun must be boolean");
if (name === "get_category_sections") {
if (params.lang !== undefined && (typeof params.lang !== "string" || !params.lang)) return failure("INVALID_INPUT", "lang must be a non-empty string");
return result((await api.get(categoryPath(categoryId), { params: { includeInactive: params.includeInactive === false ? false : true, lang: params.lang || "th" } })).data);
}
const current = await sectionsForEdit(api, categoryId);
const preview = (operation: string, wouldSend: unknown, note?: string) => result({ dryRun: true, operation, categoryId, current, wouldSend, ...(note ? { note } : {}) });
if (name === "seed_category_sections") {
if (current.length) return failure("CONFLICT", "Category sections already exist", { sectionIds: current.map((section) => section.id) });
if (params.dryRun === true) return preview(name, { method: "POST", path: `${categoryPath(categoryId)}/seed-default` }, "Backend will create CATEGORY_HEADER, CATALOG_GRID and USP_BAR. No atomic version check.");
return result((await api.post(`${categoryPath(categoryId)}/seed-default`)).data);
}
if (name === "add_category_section" || name === "update_category_section") {
if (name === "add_category_section" && (!object(params.payload) || !Object.keys(params.payload).length)) return failure("INVALID_INPUT", "payload must be a non-empty object");
if (params.type !== undefined && (typeof params.type !== "string" || !CATEGORY_SECTION_TYPES.includes(params.type.toUpperCase() as any))) return failure("INVALID_INPUT", `type must be one of ${CATEGORY_SECTION_TYPES.join(", ")}`);
if (params.payload !== undefined && !object(params.payload)) return failure("INVALID_INPUT", "payload must be an object");
if (params.order !== undefined && (typeof params.order !== "number" || !Number.isFinite(params.order))) return failure("INVALID_INPUT", "order must be a finite number");
for (const key of ["isActive", "isLocked"] as const) if (params[key] !== undefined && typeof params[key] !== "boolean") return failure("INVALID_INPUT", `${key} must be boolean`);
const body = Object.fromEntries(["type", "payload", "order", "isActive", "isLocked"].filter((key) => params[key] !== undefined).map((key) => [key, key === "type" ? (params[key] as string).toUpperCase() : params[key]]));
if (name === "add_category_section") {
if (!body.type) return failure("INVALID_INPUT", "type is required");
if (params.dryRun === true) return preview(name, body, current.length ? "Backend validates payload on write. No atomic version check." : "Empty category: backend seeds defaults first, and may reuse a matching default type. Backend validates payload on write.");
return result((await api.post(categoryPath(categoryId), body)).data);
}
if (!positiveId(params.sectionId)) return failure("INVALID_INPUT", "sectionId must be a positive integer");
const existing = current.find((section) => section.id === params.sectionId);
if (!existing) return failure("NOT_FOUND", "Section does not belong to this category", { sectionId: params.sectionId });
if (existing.isLocked && ((body.type && body.type !== existing.type) || body.isActive === false || body.isLocked === false)) return failure("CONFLICT", "Locked section cannot change type, be hidden or unlocked");
if (!Object.keys(body).length) return failure("INVALID_INPUT", "Provide at least one section field");
if (params.dryRun === true) return preview(name, { sectionId: params.sectionId, ...body }, "Backend validates complete payload on write. No atomic version check.");
return result((await api.put(`/categories/sections/${params.sectionId}`, body)).data);
}
if (name === "reorder_category_sections") {
const items = params.sections;
if (!Array.isArray(items) || items.length !== current.length || items.some((item) => !object(item) || !positiveId(item.id) || typeof item.order !== "number" || !Number.isFinite(item.order)) || new Set(items.map((item) => item.id)).size !== items.length || items.some((item) => !current.some((section) => section.id === item.id))) return failure("INVALID_INPUT", "sections must contain every current section ID exactly once with a finite numeric order", { currentIds: current.map((section) => section.id) });
const body = { sections: items };
if (params.dryRun === true) return preview(name, body, "Backend does not enforce an atomic version check.");
return result((await api.put(`${categoryPath(categoryId)}/reorder`, body)).data);
}
if (name === "delete_category_section") {
if (!positiveId(params.sectionId)) return failure("INVALID_INPUT", "sectionId must be a positive integer");
const existing = current.find((section) => section.id === params.sectionId);
if (!existing) return failure("NOT_FOUND", "Section does not belong to this category", { sectionId: params.sectionId });
if (existing.isLocked) return failure("CONFLICT", "Locked section cannot be deleted");
if (params.dryRun === true) return preview(name, { sectionId: params.sectionId }, "Backend does not enforce an atomic version check.");
return result((await api.delete(`/categories/sections/${params.sectionId}`)).data);
}
return null;
} catch (error) {
return apiError(error);
}
}