x
This commit is contained in:
1 parent
ae3799d8ce
commit
0341b30b6d
9 files changed
+947
-51
No files matched your search
+228
-26
@@ -5,19 +5,88 @@ import {
|
||||
applyMenuMoveNode,
|
||||
applyMenuRemoveNode,
|
||||
applyMenuUpdateTranslation,
|
||||
buildMenuMutationPreview,
|
||||
collectMenuLinksFromTree,
|
||||
createMenuSnapshot,
|
||||
describeMenuNode,
|
||||
normalizeMenuPayloadForWrite,
|
||||
restoreMenuSnapshot,
|
||||
validateMenuItems,
|
||||
validateMenuNodeData,
|
||||
validateMenuTranslations,
|
||||
validateMenuTreeSnapshot,
|
||||
cloneMenuValue,
|
||||
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 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_menu",
|
||||
@@ -82,9 +151,19 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "get_mega_menu_tree",
|
||||
description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the 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.",
|
||||
@@ -110,7 +189,7 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "bulk_menu_operations",
|
||||
description: "Apply multiple menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.",
|
||||
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: {
|
||||
@@ -127,8 +206,11 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
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: { type: "object", description: "Translation map for update_translation" },
|
||||
nodeData: { type: "object", description: "Node data for add_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"]
|
||||
}
|
||||
@@ -139,28 +221,31 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "add_menu_node",
|
||||
description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.",
|
||||
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: { type: "object", description: "Node data including label, url, translations, etc." }
|
||||
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 6-language translations for a specific node in the Mega Menu",
|
||||
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: {
|
||||
type: "object",
|
||||
description: "Object containing language codes as keys (th, en, etc.) and translated strings as values."
|
||||
...localizedTextSchema,
|
||||
description: "Object containing locale codes as keys and translated strings as values. Unsupported keys are rejected."
|
||||
}
|
||||
},
|
||||
required: ["nodeId", "translations"]
|
||||
@@ -359,6 +444,39 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
}
|
||||
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);
|
||||
@@ -413,7 +531,9 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
const menu = res.data;
|
||||
const result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations);
|
||||
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||
const result = applyBulkMenuOperations(beforePayload, params.operations);
|
||||
const validation = validateMenuTreeSnapshot(result.payload);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
@@ -421,7 +541,17 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { nodeCount: result.payload.length } },
|
||||
{
|
||||
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
|
||||
)
|
||||
@@ -436,11 +566,23 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
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, applied: result.applied, nodeCount: normalized.payload.length }, null, 2)
|
||||
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
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -454,21 +596,36 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
if (validation.issues.length > 0) {
|
||||
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
||||
}
|
||||
const result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData);
|
||||
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: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||
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, nodeId: result.nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
||||
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;
|
||||
@@ -479,63 +636,108 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
}
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations);
|
||||
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: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||
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, nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
||||
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 result = applyMenuMoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId, newParentId, orderIndex);
|
||||
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: true, dryRun: true, operation: "move_menu_node", preview: { nodeCount: result.payload.length } }, null, 2)
|
||||
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, nodeCount: result.payload.length }, null, 2) }] };
|
||||
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 result = applyMenuRemoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId);
|
||||
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: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||
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, nodeCount: result.payload.length }, null, 2) }] };
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user