974 lines
36 KiB
TypeScript
974 lines
36 KiB
TypeScript
import type { AxiosInstance } from "axios";
|
|
import {
|
|
applyBulkMenuOperations,
|
|
applyMenuAddNode,
|
|
applyMenuMoveNode,
|
|
applyMenuRemoveNode,
|
|
applyMenuUpdateTranslation,
|
|
buildMenuMutationPreview,
|
|
collectMenuLinksFromTree,
|
|
createMenuSnapshot,
|
|
normalizeMenuPayloadForWrite,
|
|
restoreMenuSnapshot,
|
|
validateMenuItems,
|
|
validateMenuNodeData,
|
|
validateMenuTranslations,
|
|
validateMenuTreeSnapshot,
|
|
isPlainObject,
|
|
SUPPORTED_MENU_LOCALES,
|
|
} from "./menu-tools";
|
|
|
|
const localizedTextSchema = {
|
|
type: "object",
|
|
description: `Localized text. Allowed locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. Provide at least th or en; include lo and it when creating production-ready header menu content.`,
|
|
properties: Object.fromEntries(SUPPORTED_MENU_LOCALES.map((locale) => [locale, { type: "string" }])),
|
|
additionalProperties: false,
|
|
} as const;
|
|
|
|
const footerMenuNameSchema = {
|
|
type: "string",
|
|
enum: ["footer-explore", "footer-support"],
|
|
description: "Footer menu managed by apps/admin Menu Manager.",
|
|
} as const;
|
|
|
|
const footerWriteControlSchema = {
|
|
dryRun: {
|
|
type: "boolean",
|
|
description: "Validate and preview the operation without saving. Defaults to false.",
|
|
},
|
|
expectedVersion: {
|
|
type: "string",
|
|
description: "Version returned by get_footer_menu. The backend rejects stale writes.",
|
|
},
|
|
} as const;
|
|
|
|
const menuLinkNodeSchema = {
|
|
type: "object",
|
|
description: "Mega Menu Link node. Valid under a Column or another Link. Renders as a clickable link; child links are shown inline up to the desktop limit.",
|
|
properties: {
|
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
|
label: localizedTextSchema,
|
|
url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" },
|
|
icon: { type: "string", description: "Optional icon registry key" },
|
|
imageUrl: { type: "string", description: "Optional hover/preview image URL" },
|
|
links: { type: "array", description: "Optional child links", items: { type: "object" } },
|
|
},
|
|
required: ["label", "url"],
|
|
additionalProperties: true,
|
|
} as const;
|
|
|
|
const menuColumnNodeSchema = {
|
|
type: "object",
|
|
description: "Mega Menu Column node. Valid only under Root. Contains links; it is not directly clickable.",
|
|
properties: {
|
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
|
title: localizedTextSchema,
|
|
icon: { type: "string", description: "Optional icon registry key" },
|
|
links: { type: "array", items: menuLinkNodeSchema },
|
|
},
|
|
required: ["title", "links"],
|
|
additionalProperties: true,
|
|
} as const;
|
|
|
|
const rootMenuNodeSchema = {
|
|
type: "object",
|
|
description: "Mega Menu Root node. Valid only under root. Desktop renders root labels in the header; columns open in the mega panel on hover.",
|
|
properties: {
|
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
|
label: localizedTextSchema,
|
|
url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" },
|
|
rootImageUrl: { type: "string", description: "Optional fallback image for featured preview" },
|
|
columns: { type: "array", items: menuColumnNodeSchema },
|
|
featured: {
|
|
type: "object",
|
|
description: "Optional desktop featured card. If enabled, provide meaningful localized text and imageUrl/url where possible.",
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
sectionTitle: localizedTextSchema,
|
|
badge: localizedTextSchema,
|
|
title: localizedTextSchema,
|
|
subtitle: localizedTextSchema,
|
|
ctaLabel: localizedTextSchema,
|
|
price: { type: "string" },
|
|
imageUrl: { type: "string" },
|
|
url: { type: "string" },
|
|
focalPoint: {
|
|
type: "object",
|
|
properties: { x: { type: "string" }, y: { type: "string" } },
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
required: ["label", "url", "columns"],
|
|
additionalProperties: true,
|
|
} as const;
|
|
|
|
export const MENU_TOOL_DEFINITIONS = [
|
|
{
|
|
name: "get_footer_menu",
|
|
description: "Read footer-explore or footer-support in a compact, language-specific format. Use lang=all only when every translation is required.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
lang: {
|
|
type: "string",
|
|
enum: [...SUPPORTED_MENU_LOCALES, "all"],
|
|
description: "Language to resolve. Defaults to th; all returns all translations.",
|
|
},
|
|
fallback: {
|
|
type: "boolean",
|
|
description: "Allow the backend to fall back to the base label when the requested translation is missing. Defaults to true.",
|
|
},
|
|
},
|
|
required: ["menuName"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "add_footer_menu_item",
|
|
description: "Add one item to footer-explore or footer-support without replacing the menu. Use dryRun first and pass the version from get_footer_menu.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
label: { type: "string", description: "Base label for the item." },
|
|
url: { type: "string", description: "Internal path or supported external URL." },
|
|
order: { type: "number", description: "Display order." },
|
|
translations: {
|
|
type: "object",
|
|
properties: Object.fromEntries(SUPPORTED_MENU_LOCALES.map((locale) => [locale, { type: "string" }])),
|
|
additionalProperties: false,
|
|
description: "Optional translated labels keyed by locale.",
|
|
},
|
|
...footerWriteControlSchema,
|
|
},
|
|
required: ["menuName", "label", "url"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "update_footer_menu_item",
|
|
description: "Partially update one footer menu item while preserving omitted fields and translations.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
itemId: { type: "number", description: "Existing footer menu item ID." },
|
|
label: { type: "string" },
|
|
url: { type: "string" },
|
|
order: { type: "number" },
|
|
...footerWriteControlSchema,
|
|
},
|
|
required: ["menuName", "itemId"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "delete_footer_menu_item",
|
|
description: "Delete one item from footer-explore or footer-support. Use dryRun first and pass expectedVersion.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
itemId: { type: "number", description: "Existing footer menu item ID." },
|
|
...footerWriteControlSchema,
|
|
},
|
|
required: ["menuName", "itemId"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "reorder_footer_menu_items",
|
|
description: "Reorder a footer menu using the complete ordered list of its item IDs.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
itemIds: {
|
|
type: "array",
|
|
items: { type: "number" },
|
|
minItems: 1,
|
|
description: "Every current item ID, exactly once, in the desired order.",
|
|
},
|
|
...footerWriteControlSchema,
|
|
},
|
|
required: ["menuName", "itemIds"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "update_footer_menu_item_translation",
|
|
description: "Update one translated label on a footer menu item without changing its base label, URL, order, or other languages.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
menuName: footerMenuNameSchema,
|
|
itemId: { type: "number", description: "Existing footer menu item ID." },
|
|
lang: {
|
|
type: "string",
|
|
enum: SUPPORTED_MENU_LOCALES,
|
|
description: "Translation locale.",
|
|
},
|
|
label: { type: "string", description: "Translated label." },
|
|
...footerWriteControlSchema,
|
|
},
|
|
required: ["menuName", "itemId", "lang", "label"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "get_menu",
|
|
description: "Fetch a specific menu and its items (including translations)",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }
|
|
},
|
|
required: ["name"]
|
|
}
|
|
},
|
|
{
|
|
name: "upsert_menu",
|
|
description: "Update or create a complete menu by replacing its items. Optional payload/isActive support covers tree-based menus and menu state. Uses proper diff & sync for translations.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
|
payload: {
|
|
oneOf: [{ type: "object" }, { type: "array" }],
|
|
description: "Optional menu payload for tree-based menus such as main"
|
|
},
|
|
isActive: { type: "boolean", description: "Optional active state for the menu" },
|
|
rootImage: { type: "string", description: "Optional root image for the menu" },
|
|
items: {
|
|
type: "array",
|
|
description: "Array of menu items",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "number", description: "ID of existing item (optional)" },
|
|
label: { type: "string", description: "Label of the menu item" },
|
|
url: { type: "string", description: "URL of the menu item" },
|
|
order: { type: "number", description: "Order sequence" },
|
|
columnImage: { type: "string", description: "Optional column image for the menu item" },
|
|
hoverImage: { type: "string", description: "Optional hover image for the menu item" },
|
|
translations: {
|
|
type: "array",
|
|
description: "Optional array of translations",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
langCode: { type: "string" },
|
|
label: { type: "string" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
required: ["label", "url", "order"]
|
|
}
|
|
}
|
|
},
|
|
required: ["name"]
|
|
}
|
|
},
|
|
{
|
|
name: "audit_missing_pages",
|
|
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
|
|
inputSchema: { type: "object", properties: {} }
|
|
},
|
|
{
|
|
name: "get_mega_menu_tree",
|
|
description: `Read Mega Menu (main) payload, auto-generate nodeId for all structural nodes if missing, and return the tree. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
|
inputSchema: { type: "object", properties: {} }
|
|
},
|
|
{
|
|
name: "validate_mega_menu_tree",
|
|
description: "Validate the current Mega Menu (main) tree for LLM-safe editing. Reports missing locales, invalid node shape, duplicate nodeId, unsupported translation keys, placeholder text, URL issues, and desktop render limits.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
includeTree: { type: "boolean", description: "Include the normalized menu tree in the response. Default false." }
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: "capture_menu_snapshot",
|
|
description: "Capture the current state of a menu so it can be restored later.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string", description: "Name of the menu to snapshot (e.g. main, footer-explore)" }
|
|
},
|
|
required: ["name"]
|
|
}
|
|
},
|
|
{
|
|
name: "restore_menu_snapshot",
|
|
description: "Restore a previously captured menu snapshot by snapshotId.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
snapshotId: { type: "string", description: "Snapshot identifier returned by capture_menu_snapshot" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
|
},
|
|
required: ["snapshotId"]
|
|
}
|
|
},
|
|
{
|
|
name: "bulk_menu_operations",
|
|
description: `Apply multiple Mega Menu operations in a single transaction. Workflow: capture_menu_snapshot -> validate_mega_menu_tree -> bulk_menu_operations with dryRun true -> user approval -> apply. Supports add_node, update_translation, move_node, and remove_node. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string", description: "Name of the menu to modify (e.g. main, footer-explore)" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
|
operations: {
|
|
type: "array",
|
|
description: "Array of menu operations to apply in order",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
type: { type: "string", enum: ["add_node", "update_translation", "move_node", "remove_node"] },
|
|
parentId: { type: "string", description: "Parent node ID for add_node" },
|
|
nodeId: { type: "string", description: "Target node ID for update/move/remove" },
|
|
newParentId: { type: "string", description: "New parent node ID for move_node" },
|
|
orderIndex: { type: "number", description: "Insertion index for move_node" },
|
|
translations: { ...localizedTextSchema, description: "Translation map for update_translation. Only locale keys are accepted." },
|
|
nodeData: {
|
|
oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema],
|
|
description: "Node data for add_node. Shape depends on parent: parentId=root => Root node; parent is Root => Column node; parent is Column/Link => Link node."
|
|
}
|
|
},
|
|
required: ["type"]
|
|
}
|
|
}
|
|
},
|
|
required: ["name", "operations"]
|
|
}
|
|
},
|
|
{
|
|
name: "add_menu_node",
|
|
description: `Add a new node to the Mega Menu tree. Structure is Root -> Column -> Link -> Child Link. Do not add a Link directly under root. Use dryRun first. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
|
nodeData: {
|
|
oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema],
|
|
description: "Node data. If parentId is root/omitted, provide a Root node with label, url, and columns. If parent is a Root node, provide a Column node with title and links. If parent is a Column/Link, provide a Link node with label and url."
|
|
}
|
|
},
|
|
required: ["nodeData"]
|
|
}
|
|
},
|
|
{
|
|
name: "update_menu_translation",
|
|
description: `Update localized text for a specific node in the Mega Menu. Accepts only supported locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. This updates label for root/link nodes or title for column nodes.`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
nodeId: { type: "string", description: "UUID of the node" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
|
translations: {
|
|
...localizedTextSchema,
|
|
description: "Object containing locale codes as keys and translated strings as values. Unsupported keys are rejected."
|
|
}
|
|
},
|
|
required: ["nodeId", "translations"]
|
|
}
|
|
},
|
|
{
|
|
name: "move_menu_node",
|
|
description: "Move a node to a new parent or change its order",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
nodeId: { type: "string" },
|
|
newParentId: { type: "string", description: "UUID of new parent, or 'root'" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
|
orderIndex: { type: "number", description: "New index in the parent's children array" }
|
|
},
|
|
required: ["nodeId"]
|
|
}
|
|
},
|
|
{
|
|
name: "remove_menu_node",
|
|
description: "Remove a node and all its children from the Mega Menu",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
nodeId: { type: "string" },
|
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
|
},
|
|
required: ["nodeId"]
|
|
}
|
|
}
|
|
] as const;
|
|
|
|
export const MENU_TOOL_NAMES = new Set<string>(MENU_TOOL_DEFINITIONS.map((tool) => tool.name));
|
|
|
|
export function getLegacyFooterMenuDeprecation(params: any) {
|
|
const rejectedFields = ["exploreLinks", "collectionLinks", "supportLinks"]
|
|
.filter((field) => params?.[field] !== undefined);
|
|
if (rejectedFields.length === 0) return null;
|
|
|
|
return {
|
|
success: false,
|
|
code: "FOOTER_MENU_REPLACEMENT_DEPRECATED",
|
|
partialWrite: false,
|
|
rejectedFields,
|
|
message: "Footer menu arrays cannot be replaced through update_footer_content. Use get_footer_menu and the Footer-specific item tools. Website Collections are managed through Categories.",
|
|
};
|
|
}
|
|
|
|
function compactFooterResponse(data: any, operation: string, params: any) {
|
|
const source = data?.data ?? data ?? {};
|
|
const compact: Record<string, unknown> = {
|
|
success: source.success ?? true,
|
|
operation: source.operation ?? operation,
|
|
menuName: source.menuName ?? source.name ?? params.menuName,
|
|
};
|
|
|
|
for (const key of [
|
|
"dryRun",
|
|
"version",
|
|
"lang",
|
|
"item",
|
|
"items",
|
|
"itemId",
|
|
"itemIds",
|
|
"itemCount",
|
|
"label",
|
|
"before",
|
|
"after",
|
|
"deletedItemId",
|
|
"translatedItemId",
|
|
"translatedPaths",
|
|
"changes",
|
|
"warnings",
|
|
"code",
|
|
"error",
|
|
"partialWrite",
|
|
]) {
|
|
if (source[key] !== undefined) compact[key] = source[key];
|
|
}
|
|
|
|
if (compact.dryRun === undefined && params.dryRun !== undefined) {
|
|
compact.dryRun = Boolean(params.dryRun);
|
|
}
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
|
|
}
|
|
|
|
function footerWriteBody(params: any, fields: string[]) {
|
|
const body: Record<string, unknown> = {};
|
|
for (const field of fields) {
|
|
if (params[field] !== undefined) body[field] = params[field];
|
|
}
|
|
body.dryRun = Boolean(params.dryRun);
|
|
if (params.expectedVersion !== undefined) body.expectedVersion = params.expectedVersion;
|
|
return body;
|
|
}
|
|
|
|
export async function handleMenuTool(api: AxiosInstance, name: string, params: any) {
|
|
if (!MENU_TOOL_NAMES.has(name)) return null;
|
|
|
|
switch (name) {
|
|
case "get_footer_menu": {
|
|
const query = new URLSearchParams();
|
|
query.set("lang", params.lang || "th");
|
|
query.set("fallback", params.fallback === false ? "false" : "true");
|
|
const res = await api.get(`/menus/footer/${params.menuName}?${query.toString()}`);
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "add_footer_menu_item": {
|
|
const body = footerWriteBody(params, ["label", "url", "order", "translations"]);
|
|
const res = await api.post(`/menus/footer/${params.menuName}/items`, body);
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "update_footer_menu_item": {
|
|
const body = footerWriteBody(params, ["label", "url", "order"]);
|
|
const res = await api.patch(`/menus/footer/${params.menuName}/items/${params.itemId}`, body);
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "delete_footer_menu_item": {
|
|
const body = footerWriteBody(params, []);
|
|
const res = await api.delete(`/menus/footer/${params.menuName}/items/${params.itemId}`, { data: body });
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "reorder_footer_menu_items": {
|
|
const body = footerWriteBody(params, ["itemIds"]);
|
|
const res = await api.put(`/menus/footer/${params.menuName}/items/reorder`, body);
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "update_footer_menu_item_translation": {
|
|
const body = footerWriteBody(params, ["label"]);
|
|
const res = await api.patch(
|
|
`/menus/footer/${params.menuName}/items/${params.itemId}/translations/${params.lang}`,
|
|
body,
|
|
);
|
|
return compactFooterResponse(res.data, name, params);
|
|
}
|
|
case "get_menu": {
|
|
const res = await api.get(`/menus/${params.name}`);
|
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
}
|
|
case "upsert_menu": {
|
|
const dryRun = !!params.dryRun;
|
|
let existingMenu: any = null;
|
|
try {
|
|
const getRes = await api.get(`/menus/${params.name}`);
|
|
existingMenu = getRes.data;
|
|
} catch (e) {
|
|
// Menu might not exist yet
|
|
}
|
|
|
|
const items = params.items !== undefined ? (Array.isArray(params.items) ? params.items : []) : (existingMenu?.items || []);
|
|
const body: any = { items };
|
|
const itemCheck = validateMenuItems(items);
|
|
if (itemCheck.issues.length > 0) {
|
|
throw new Error(`Invalid menu items: ${itemCheck.issues.join("; ")}`);
|
|
}
|
|
|
|
if (params.payload !== undefined) {
|
|
body.payload = params.payload;
|
|
} else if (existingMenu?.payload !== undefined) {
|
|
body.payload = existingMenu.payload;
|
|
}
|
|
|
|
if (params.isActive !== undefined) {
|
|
body.isActive = params.isActive;
|
|
} else if (existingMenu?.isActive !== undefined) {
|
|
body.isActive = existingMenu.isActive;
|
|
}
|
|
|
|
if (params.rootImage !== undefined) {
|
|
body.rootImage = params.rootImage;
|
|
} else if (existingMenu?.rootImage !== undefined) {
|
|
body.rootImage = existingMenu.rootImage;
|
|
}
|
|
|
|
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
|
throw new Error("Invalid menu payload: payload must be an object or array");
|
|
}
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: true,
|
|
dryRun: true,
|
|
operation: "upsert_menu",
|
|
menuName: params.name,
|
|
preview: {
|
|
name: params.name,
|
|
itemCount: items.length,
|
|
hasPayload: !!body.payload,
|
|
isActive: body.isActive,
|
|
rootImage: body.rootImage || null
|
|
},
|
|
warnings: itemCheck.warnings
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
const res = await api.put(`/menus/${params.name}`, body);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: true,
|
|
operation: "upsert_menu",
|
|
name: res.data.name,
|
|
isActive: res.data.isActive,
|
|
itemCount: res.data.items?.length || 0,
|
|
hasPayload: !!res.data.payload,
|
|
rootImage: res.data.rootImage || null
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
case "audit_missing_pages": {
|
|
const menusRes = await api.get("/menus");
|
|
const menus = menusRes.data || [];
|
|
const pagesRes = await api.get("/pages");
|
|
const pages = pagesRes.data || [];
|
|
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
|
|
const hardcodedRoutes = ["/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"];
|
|
|
|
const missingLinks: any[] = [];
|
|
const duplicates: any[] = [];
|
|
const treeIssues: any[] = [];
|
|
|
|
menus.forEach((menu: any) => {
|
|
const seenUrls = new Set<string>();
|
|
const urls = [
|
|
...(Array.isArray(menu.items)
|
|
? menu.items.filter((item: any) => item?.url).map((item: any) => ({ url: item.url, label: item.label, source: "items" as const }))
|
|
: []),
|
|
...(Array.isArray(menu.payload) ? collectMenuLinksFromTree(menu.payload) : []),
|
|
];
|
|
const uniqueUrls = [...new Map(urls.map((entry) => [entry.url, entry])).values()];
|
|
|
|
uniqueUrls.forEach(({ url, label, source }) => {
|
|
if (seenUrls.has(url)) {
|
|
duplicates.push({ menuName: menu.name, url, label: label || url, source });
|
|
return;
|
|
}
|
|
seenUrls.add(url);
|
|
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
|
|
if (hardcodedRoutes.includes(url)) return;
|
|
if (url.startsWith("/collections/")) return;
|
|
if (!createdSlugs.includes(url)) {
|
|
missingLinks.push({
|
|
menuName: menu.name,
|
|
label: label || url,
|
|
url,
|
|
suggestedSlug: url.startsWith("/") ? url.substring(1) : url,
|
|
source,
|
|
});
|
|
}
|
|
});
|
|
|
|
if (Array.isArray(menu.payload)) {
|
|
const treeCheck = validateMenuTreeSnapshot(menu.payload);
|
|
treeIssues.push({ menuName: menu.name, issues: treeCheck.issues, warnings: treeCheck.warnings });
|
|
}
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({ missingPages: missingLinks, duplicates, treeIssues }, null, 2)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
case "get_mega_menu_tree": {
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const normalized = normalizeMenuPayloadForWrite(menu.payload);
|
|
if (normalized.modified) {
|
|
await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] });
|
|
}
|
|
return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] };
|
|
}
|
|
case "validate_mega_menu_tree": {
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const normalized = normalizeMenuPayloadForWrite(menu.payload);
|
|
if (normalized.modified) {
|
|
await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] });
|
|
}
|
|
const validation = validateMenuTreeSnapshot(normalized.payload);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: validation.issues.length === 0,
|
|
operation: "validate_mega_menu_tree",
|
|
menuName: "main",
|
|
supportedLocales: validation.supportedLocales,
|
|
summary: validation.summary,
|
|
issueCount: validation.issues.length,
|
|
warningCount: validation.warnings.length,
|
|
issues: validation.issues,
|
|
warnings: validation.warnings,
|
|
diagnostics: validation.diagnostics,
|
|
tree: params.includeTree ? normalized.payload : undefined,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
case "capture_menu_snapshot": {
|
|
const res = await api.get(`/menus/${params.name}`);
|
|
const snapshot = createMenuSnapshot(res.data);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: true,
|
|
operation: "capture_menu_snapshot",
|
|
snapshotId: snapshot.snapshotId,
|
|
name: snapshot.name,
|
|
createdAt: snapshot.createdAt,
|
|
itemCount: snapshot.items.length,
|
|
hasTreePayload: snapshot.payload.length > 0
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
case "restore_menu_snapshot": {
|
|
const dryRun = !!params.dryRun;
|
|
const snapshot = restoreMenuSnapshot(params.snapshotId);
|
|
const preview = { name: snapshot.name, payload: snapshot.payload, items: snapshot.items, isActive: snapshot.isActive };
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({ success: true, dryRun: true, operation: "restore_menu_snapshot", snapshotId: params.snapshotId, preview }, null, 2)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
const res = await api.put(`/menus/${snapshot.name}`, {
|
|
payload: snapshot.payload,
|
|
items: snapshot.items,
|
|
isActive: snapshot.isActive
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, snapshotId: params.snapshotId, menu: res.data }, null, 2) }] };
|
|
}
|
|
case "bulk_menu_operations": {
|
|
const dryRun = !!params.dryRun;
|
|
if (!Array.isArray(params.operations) || params.operations.length === 0) {
|
|
throw new Error("operations must be a non-empty array");
|
|
}
|
|
|
|
const res = await api.get(`/menus/${params.name}`);
|
|
const menu = res.data;
|
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
|
const result = applyBulkMenuOperations(beforePayload, params.operations);
|
|
const validation = validateMenuTreeSnapshot(result.payload);
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: validation.issues.length === 0,
|
|
dryRun: true,
|
|
operation: "bulk_menu_operations",
|
|
menuName: params.name,
|
|
warnings: [...result.warnings, ...validation.warnings],
|
|
issues: validation.issues,
|
|
diagnostics: validation.diagnostics,
|
|
applied: result.applied,
|
|
preview: buildMenuMutationPreview(beforePayload, result.payload),
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
const normalized = normalizeMenuPayloadForWrite(result.payload);
|
|
const writeBody: any = { payload: normalized.payload, items: menu.items || [] };
|
|
if (menu.isActive !== undefined) {
|
|
writeBody.isActive = menu.isActive;
|
|
}
|
|
const writeRes = await api.put(`/menus/${params.name}`, writeBody);
|
|
const writtenCheck = validateMenuTreeSnapshot(normalized.payload);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: true,
|
|
operation: "bulk_menu_operations",
|
|
menuName: params.name,
|
|
warnings: [...result.warnings, ...writtenCheck.warnings],
|
|
applied: result.applied,
|
|
summary: writtenCheck.summary,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
case "add_menu_node": {
|
|
const { parentId, nodeData } = params;
|
|
const dryRun = !!params.dryRun;
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const validation = validateMenuNodeData(nodeData, parentId);
|
|
if (validation.issues.length > 0) {
|
|
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
|
}
|
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
|
const result = applyMenuAddNode(beforePayload, parentId, nodeData);
|
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: treeCheck.issues.length === 0,
|
|
dryRun: true,
|
|
operation: "add_menu_node",
|
|
nodeId: result.nodeId,
|
|
warnings: [...validation.warnings, ...treeCheck.warnings],
|
|
issues: treeCheck.issues,
|
|
diagnostics: treeCheck.diagnostics,
|
|
preview: buildMenuMutationPreview(beforePayload, result.payload, result.nodeId),
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "add_menu_node", nodeId: result.nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
|
}
|
|
case "update_menu_translation": {
|
|
const { nodeId, translations } = params;
|
|
const dryRun = !!params.dryRun;
|
|
const validation = validateMenuTranslations(translations);
|
|
if (validation.issues.length > 0) {
|
|
throw new Error(`Invalid translations: ${validation.issues.join("; ")}`);
|
|
}
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
|
const result = applyMenuUpdateTranslation(beforePayload, nodeId, translations);
|
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: treeCheck.issues.length === 0,
|
|
dryRun: true,
|
|
operation: "update_menu_translation",
|
|
nodeId,
|
|
warnings: [...validation.warnings, ...treeCheck.warnings],
|
|
issues: treeCheck.issues,
|
|
diagnostics: treeCheck.diagnostics,
|
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "update_menu_translation", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
|
}
|
|
case "move_menu_node": {
|
|
const { nodeId, newParentId, orderIndex } = params;
|
|
const dryRun = !!params.dryRun;
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
|
const result = applyMenuMoveNode(beforePayload, nodeId, newParentId, orderIndex);
|
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: treeCheck.issues.length === 0,
|
|
dryRun: true,
|
|
operation: "move_menu_node",
|
|
nodeId,
|
|
warnings: treeCheck.warnings,
|
|
issues: treeCheck.issues,
|
|
diagnostics: treeCheck.diagnostics,
|
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "move_menu_node", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
|
}
|
|
case "remove_menu_node": {
|
|
const { nodeId } = params;
|
|
const dryRun = !!params.dryRun;
|
|
const res = await api.get(`/menus/main`);
|
|
const menu = res.data;
|
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
|
const result = applyMenuRemoveNode(beforePayload, nodeId);
|
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
|
|
|
if (dryRun) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: treeCheck.issues.length === 0,
|
|
dryRun: true,
|
|
operation: "remove_menu_node",
|
|
removedNode: result.removedNode,
|
|
warnings: treeCheck.warnings,
|
|
issues: treeCheck.issues,
|
|
diagnostics: treeCheck.diagnostics,
|
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "remove_menu_node", removedNode: result.removedNode, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|