diff --git a/apps/backend/public/uploads/864f2dc6-dcdf-470f-a94a-1ac445ddc39f.png b/apps/backend/public/uploads/864f2dc6-dcdf-470f-a94a-1ac445ddc39f.png new file mode 100644 index 0000000..fe1837d Binary files /dev/null and b/apps/backend/public/uploads/864f2dc6-dcdf-470f-a94a-1ac445ddc39f.png differ diff --git a/apps/backend/public/uploads/d9ae7f74-8db6-4944-805a-15a8cb4d37da.png b/apps/backend/public/uploads/d9ae7f74-8db6-4944-805a-15a8cb4d37da.png new file mode 100644 index 0000000..fe1837d Binary files /dev/null and b/apps/backend/public/uploads/d9ae7f74-8db6-4944-805a-15a8cb4d37da.png differ diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index caace4f..6f99e32 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -1,4 +1,3 @@ -import crypto from "crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -7,236 +6,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import FormData from "form-data"; -import { PAGE_SECTION_TYPES } from "@homaroma/types"; - -function getChildrenArray(node: any, isRoot?: boolean): any[] { - if (Array.isArray(node.columns)) return node.columns; - if (Array.isArray(node.links)) return node.links; - if (Array.isArray(node.items)) return node.items; - if (Array.isArray(node.children)) return node.children; - if (isRoot) { - node.columns = []; - return node.columns; - } - node.links = []; - return node.links; -} - -function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any, parent: any, list: any[], index: number } | null { - for (let i = 0; i < tree.length; i++) { - const item = tree[i]; - if (item && typeof item === 'object') { - if (item.nodeId === targetId) { - return { node: item, parent, list: tree, index: i }; - } - for (const key of Object.keys(item)) { - if (Array.isArray(item[key])) { - const res = findNodeAndParent(item[key], targetId, item, item[key]); - if (res) return res; - } - } - } - } - return null; -} - -const localizedStringSchema = { - oneOf: [ - { type: "string" }, - { - type: "object", - additionalProperties: { type: "string" } - } - ] -}; - -const localizedStringListSchema = { - type: "array", - items: localizedStringSchema -}; - -const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }'; -const localizedStringListExample = `[ ${localizedStringExample} ]`; - -const SECTION_TOOL_CONFIGS = [ - { - name: "update_hero_article", - sectionType: "HERO_ARTICLE", - description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` }, - subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` }, - author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` }, - category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` }, - publishDate: { type: "string", description: "Plain string publish date or display date" }, - date: { type: "string", description: "Plain string alternative date field accepted by backend" }, - imageUrl: { type: "string", description: "Plain string main image URL" }, - backgroundImageUrl: { type: "string", description: "Plain string background image URL" }, - overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" } - }, - required: ["sectionId"] - }, - { - name: "update_rich_text", - sectionType: "RICH_TEXT", - description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` }, - content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` } - }, - required: ["sectionId", "content"] - }, - { - name: "update_scent_notes_breakdown", - sectionType: "SCENT_NOTES_BREAKDOWN", - description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, - description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` }, - topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` }, - heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` }, - middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` }, - baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` } - }, - required: ["sectionId"] - }, - { - name: "update_quote_callout", - sectionType: "QUOTE_CALLOUT", - description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` }, - author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` }, - role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` } - }, - required: ["sectionId"] - }, - { - name: "update_product_carousel", - sectionType: "PRODUCT_CAROUSEL", - description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, - description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` }, - collectionId: { type: "string", description: "Optional plain string collection identifier" }, - limit: { type: "number", description: "Maximum number of products to show" }, - showPrice: { type: "boolean", description: "Show product price" }, - showAddToCart: { type: "boolean", description: "Show add to cart button" }, - products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } } - }, - required: ["sectionId"] - }, - { - name: "update_accordion", - sectionType: "ACCORDION", - description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, - categories: { - type: "array", - description: "Accordion categories", - items: { - type: "object", - properties: { - name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` }, - icon: { type: "string", description: "Optional icon or emoji" }, - questions: { - type: "array", - description: "Questions under the category", - items: { - type: "object", - properties: { - q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` }, - a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` } - }, - required: ["q", "a"] - } - } - }, - required: ["name", "questions"] - } - } - }, - required: ["sectionId", "categories"] - }, - { - name: "update_contact_info", - sectionType: "CONTACT_INFO", - description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, - address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` }, - email: { type: "string", description: "Plain string email address" }, - line: { type: "string", description: "Plain string Line ID or contact" }, - hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` } - }, - required: ["sectionId", "address", "email", "line", "hours"] - }, - { - name: "update_contact_form", - sectionType: "CONTACT_FORM", - description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`, - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` }, - subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` } - }, - required: ["sectionId"] - } -] as const; - -const SECTION_TOOL_CONFIG_BY_NAME = new Map( - SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const) -); - -function buildSectionPayload(params: any, fields: readonly string[]) { - const payload: Record = {}; - for (const field of fields) { - if (params[field] !== undefined) { - payload[field] = params[field]; - } - } - return payload; -} - -function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record) { - const notes: string[] = []; - if (sectionType === "SCENT_NOTES_BREAKDOWN") { - notes.push("heartNotes and middleNotes are mirrored automatically by the backend"); - } - if (sectionType === "PRODUCT_CAROUSEL") { - notes.push("If products was omitted, the backend may populate products on read-back"); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - success: true, - tool: toolName, - sectionType, - sectionId: updatedSection?.id, - updatedFields: Object.keys(payload), - appliedPayload: payload, - notes, - section: updatedSection - }, - null, - 2 - ) - } - ] - }; -} - +import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit"; +import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit"; const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api"; const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api"; @@ -302,53 +73,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["env"] } }, - { - 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. Uses proper diff & sync for translations.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }, - 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" }, - translations: { - type: "array", - description: "Optional array of translations", - items: { - type: "object", - properties: { - langCode: { type: "string" }, - label: { type: "string" } - } - } - } - }, - required: ["label", "url", "order"] - } - } - }, - required: ["name", "items"] - } - }, + ...MENU_TOOL_DEFINITIONS, + ...SECTION_TOOL_DEFINITIONS, { name: "ping_backend", description: "Ping the backend to verify connection and authentication", @@ -638,56 +364,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["title", "slug"] } }, - { - name: "add_page_section", - description: "Add a block section to a dynamic page layout", - inputSchema: { - type: "object", - properties: { - pageId: { type: "number", description: "The numeric ID of the page" }, - type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" }, - order: { type: "number", description: "Display order sequence (starts at 1)" }, - payload: { type: "object", description: "The JSON payload matching the section type rules" }, - isActive: { type: "boolean", description: "Initial active state" } - }, - required: ["pageId", "type", "payload"] - } - }, - { - name: "update_page_section", - description: "Update an existing block section payload, order or active state", - inputSchema: { - type: "object", - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to update" }, - type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional new block type" }, - order: { type: "number", description: "Optional new display order sequence" }, - payload: { type: "object", description: "Optional new JSON payload matching the section type rules" }, - isActive: { type: "boolean", description: "Optional new active state" } - }, - required: ["sectionId"] - } - }, - ...SECTION_TOOL_CONFIGS.map((config) => ({ - name: config.name, - description: config.description, - inputSchema: { - type: "object", - properties: config.properties, - required: config.required - } - })), - { - name: "delete_page_section", - description: "Delete a block section from a dynamic page layout", - inputSchema: { - type: "object", - properties: { - sectionId: { type: "number", description: "The numeric ID of the section to delete" } - }, - required: ["sectionId"] - } - }, + ...SECTION_TOOL_DEFINITIONS, { name: "update_footer_content", description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)", @@ -731,67 +408,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } } }, - { - 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 nodes if missing, and return the tree.", - inputSchema: { type: "object", properties: {} } - }, - { - 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.", - inputSchema: { - type: "object", - properties: { - parentId: { type: "string", description: "UUID of parent node, or 'root'" }, - nodeData: { type: "object", description: "Node data including label, url, translations, etc." } - }, - required: ["nodeData"] - } - }, - { - name: "update_menu_translation", - description: "Update 6-language translations for a specific node in the Mega Menu", - inputSchema: { - type: "object", - properties: { - nodeId: { type: "string", description: "UUID of the node" }, - translations: { - type: "object", - description: "Object containing language codes as keys (th, en, etc.) and translated strings as values." - } - }, - 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'" }, - 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" } - }, - required: ["nodeId"] - } - } ], }; }); @@ -800,6 +416,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const params = request.params.arguments as any; try { + const menuToolResponse = await handleMenuTool(api, request.params.name, params); + if (menuToolResponse) { + return menuToolResponse; + } + + const sectionToolResponse = await handleSectionTool(api, request.params.name, params); + if (sectionToolResponse) { + return sectionToolResponse; + } + switch (request.params.name) { case "set_target_environment": { const env = params.env; @@ -827,10 +453,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.get(`/menus/${params.name}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } - case "upsert_menu": { - const res = await api.put(`/menus/${params.name}`, { items: params.items }); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } case "search_products": { const search = params.search ? `&search=${encodeURIComponent(params.search)}` : ""; const limit = params.limit ? `&limit=${params.limit}` : "&limit=10"; @@ -1074,57 +696,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] }; } - case "add_page_section": { - const payload: any = { - type: params.type, - payload: params.payload, - isActive: params.isActive !== false - }; - if (params.order !== undefined) payload.order = params.order; - const res = await api.post(`/pages/${params.pageId}/sections`, payload); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } - case "update_page_section": { - const payload: any = {}; - if (params.type) payload.type = params.type; - if (params.order !== undefined) payload.order = params.order; - if (params.payload) payload.payload = params.payload; - if (params.isActive !== undefined) payload.isActive = params.isActive; - const res = await api.put(`/pages/sections/${params.sectionId}`, payload); - return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload); - } - case "update_hero_article": - case "update_rich_text": - case "update_scent_notes_breakdown": - case "update_quote_callout": - case "update_product_carousel": - case "update_accordion": - case "update_contact_info": - case "update_contact_form": { - const config = SECTION_TOOL_CONFIG_BY_NAME.get(request.params.name); - if (!config) { - throw new Error(`Unknown tool config for: ${request.params.name}`); - } - - const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId")); - if (config.sectionType === "SCENT_NOTES_BREAKDOWN") { - if (payload.heartNotes !== undefined && payload.middleNotes === undefined) { - payload.middleNotes = payload.heartNotes; - } else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) { - payload.heartNotes = payload.middleNotes; - } - } - const res = await api.put(`/pages/sections/${params.sectionId}`, { - type: config.sectionType, - ...payload - }); - - return buildSectionUpdateResult(request.params.name, config.sectionType, res.data, payload); - } - case "delete_page_section": { - const res = await api.delete(`/pages/sections/${params.sectionId}`); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; - } case "update_footer_content": { const results = []; @@ -1169,173 +740,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; } - case "audit_missing_pages": { - // 1. Fetch all menus - const menusRes = await api.get("/menus"); - const menus = menusRes.data || []; - - // 2. Fetch all dynamic pages - const pagesRes = await api.get("/pages"); - const pages = pagesRes.data || []; - const createdSlugs = pages.map((p: any) => `/${p.slug}`); - - // 3. Known SPA Hardcoded Routes - const hardcodedRoutes = [ - "/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact" - ]; - - const missingLinks: any[] = []; - - // 4. Audit - menus.forEach((menu: any) => { - if (menu.items && Array.isArray(menu.items)) { - menu.items.forEach((item: any) => { - const url = item.url; - if (!url) return; - - // Skip external links - if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return; - - // Skip SPA Hardcoded routes - if (hardcodedRoutes.includes(url)) return; - - // Skip dynamic collection routes natively handled by SPA (e.g. /collections/...) - if (url.startsWith("/collections/")) return; - - // Check if it exists in created dynamic pages - if (!createdSlugs.includes(url)) { - missingLinks.push({ - menuName: menu.name, - label: item.label, - url: url, - suggestedSlug: url.startsWith("/") ? url.substring(1) : url - }); - } - }); - } - }); - - return { content: [{ type: "text", text: JSON.stringify({ missingPages: missingLinks }, null, 2) }] }; - } - case "get_mega_menu_tree": { - const res = await api.get(`/menus/main`); - const menu = res.data; - if (!menu.payload) menu.payload = []; - - let modified = false; - function ensureNodeIds(obj: any) { - if (Array.isArray(obj)) { - obj.forEach(ensureNodeIds); - } else if (obj !== null && typeof obj === 'object') { - if (!obj.nodeId) { - obj.nodeId = crypto.randomUUID(); - modified = true; - } - Object.values(obj).forEach(ensureNodeIds); - } - } - - ensureNodeIds(menu.payload); - - if (modified) { - await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] }); - } - - return { content: [{ type: "text", text: JSON.stringify(menu.payload, null, 2) }] }; - } - case "add_menu_node": { - const { parentId, nodeData } = params; - const res = await api.get(`/menus/main`); - const menu = res.data; - if (!menu.payload) menu.payload = []; - - nodeData.nodeId = crypto.randomUUID(); - - // Map translations to label/title - if (nodeData.translations) { - const labelKey = nodeData.title !== undefined ? 'title' : 'label'; - if (typeof nodeData[labelKey] === 'string') { - // fallback if it was a string - nodeData[labelKey] = { en: nodeData[labelKey] }; - } - nodeData[labelKey] = { ...(nodeData[labelKey] || {}), ...nodeData.translations }; - delete nodeData.translations; - } - - if (!parentId || parentId === 'root') { - if (nodeData.url && !nodeData.columns && !nodeData.items && !nodeData.children) { - throw new Error("Invalid schema: Cannot add a Link directly under Root. Please create a Column first."); - } - menu.payload.push(nodeData); - } else { - const found = findNodeAndParent(menu.payload, parentId); - if (!found) { - throw new Error(`Parent node ${parentId} not found`); - } - const children = getChildrenArray(found.node, found.parent === null); - children.push(nodeData); - } - - await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: nodeData.nodeId, payload: menu.payload }, null, 2) }] }; - } - case "update_menu_translation": { - const { nodeId, translations } = params; - const res = await api.get(`/menus/main`); - const menu = res.data; - if (!menu.payload) menu.payload = []; - - const found = findNodeAndParent(menu.payload, nodeId); - if (!found) throw new Error(`Node ${nodeId} not found`); - - const labelKey = found.node.title !== undefined ? 'title' : 'label'; - found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations }; - - await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, node: found.node }, null, 2) }] }; - } - case "move_menu_node": { - const { nodeId, newParentId, orderIndex } = params; - const res = await api.get(`/menus/main`); - const menu = res.data; - if (!menu.payload) menu.payload = []; - - const found = findNodeAndParent(menu.payload, nodeId); - if (!found) throw new Error(`Node ${nodeId} not found`); - - // Remove from current list - found.list.splice(found.index, 1); - - let targetList = menu.payload; - if (newParentId && newParentId !== 'root') { - const newParent = findNodeAndParent(menu.payload, newParentId); - if (!newParent) throw new Error(`New parent ${newParentId} not found`); - targetList = getChildrenArray(newParent.node); - } - - if (typeof orderIndex === 'number' && orderIndex >= 0) { - targetList.splice(orderIndex, 0, found.node); - } else { - targetList.push(found.node); - } - - await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: menu.payload }, null, 2) }] }; - } - case "remove_menu_node": { - const { nodeId } = params; - const res = await api.get(`/menus/main`); - const menu = res.data; - if (!menu.payload) menu.payload = []; - - const found = findNodeAndParent(menu.payload, nodeId); - if (!found) throw new Error(`Node ${nodeId} not found`); - - found.list.splice(found.index, 1); - - await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: menu.payload }, null, 2) }] }; - } default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/apps/mcp-server/menu-toolkit.ts b/apps/mcp-server/menu-toolkit.ts new file mode 100644 index 0000000..edf0690 --- /dev/null +++ b/apps/mcp-server/menu-toolkit.ts @@ -0,0 +1,495 @@ +import type { AxiosInstance } from "axios"; +import { + applyBulkMenuOperations, + applyMenuAddNode, + applyMenuMoveNode, + applyMenuRemoveNode, + applyMenuUpdateTranslation, + collectMenuLinksFromTree, + createMenuSnapshot, + describeMenuNode, + normalizeMenuPayloadForWrite, + restoreMenuSnapshot, + validateMenuItems, + validateMenuNodeData, + validateMenuTranslations, + validateMenuTreeSnapshot, + cloneMenuValue, + isPlainObject, +} from "./menu-tools"; + +export const MENU_TOOL_DEFINITIONS = [ + { + 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" }, + 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" }, + 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 nodes if missing, and return the tree.", + inputSchema: { type: "object", properties: {} } + }, + { + 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 menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.", + 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: { type: "object", description: "Translation map for update_translation" }, + nodeData: { type: "object", description: "Node data for add_node" } + }, + required: ["type"] + } + } + }, + required: ["name", "operations"] + } + }, + { + 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.", + 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." } + }, + required: ["nodeData"] + } + }, + { + name: "update_menu_translation", + description: "Update 6-language translations for a specific node in the Mega Menu", + 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." + } + }, + 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(MENU_TOOL_DEFINITIONS.map((tool) => tool.name)); + +export async function handleMenuTool(api: AxiosInstance, name: string, params: any) { + if (!MENU_TOOL_NAMES.has(name)) return null; + + switch (name) { + 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; + const items = Array.isArray(params.items) ? params.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; + if (params.isActive !== undefined) body.isActive = params.isActive; + 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, + items: cloneMenuValue(items), + payload: params.payload !== undefined ? cloneMenuValue(params.payload) : undefined, + isActive: params.isActive + }, + warnings: itemCheck.warnings + }, + null, + 2 + ) + } + ] + }; + } + + const res = await api.put(`/menus/${params.name}`, body); + return { content: [{ type: "text", text: JSON.stringify(res.data, 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(); + 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 "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 result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations); + + if (dryRun) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { payload: 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); + return { + content: [ + { + type: "text", + text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, menu: writeRes.data }, 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 result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData); + + if (dryRun) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: result.payload }, 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, payload: result.payload }, 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 result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations); + + if (dryRun) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: result.payload }, null, 2) + } + ] + }; + } + + await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, payload: result.payload }, 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); + + if (dryRun) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: result.payload }, null, 2) + } + ] + }; + } + + await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, 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); + + if (dryRun) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: result.payload }, null, 2) + } + ] + }; + } + + await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] }; + } + default: + return null; + } +} diff --git a/apps/mcp-server/menu-tools.ts b/apps/mcp-server/menu-tools.ts new file mode 100644 index 0000000..476872b --- /dev/null +++ b/apps/mcp-server/menu-tools.ts @@ -0,0 +1,479 @@ +import crypto from "crypto"; + +const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const; +const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const; + +function isPlainObject(value: any) { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isStructuralMenuNode(value: any) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return MENU_NODE_VALUE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key)); +} + +function cloneMenuValue(value: T): T { + const cloneFn = (globalThis as any).structuredClone; + if (typeof cloneFn === "function") { + return cloneFn(value); + } + return JSON.parse(JSON.stringify(value)); +} + +function getChildrenArray(node: any, isRoot?: boolean): any[] { + if (Array.isArray(node.columns)) return node.columns; + if (Array.isArray(node.links)) return node.links; + if (Array.isArray(node.items)) return node.items; + if (Array.isArray(node.children)) return node.children; + if (isRoot) { + node.columns = []; + return node.columns; + } + node.links = []; + return node.links; +} + +function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any; parent: any; list: any[]; index: number } | null { + for (let i = 0; i < tree.length; i++) { + const item = tree[i]; + if (item && typeof item === "object") { + if (item.nodeId === targetId) { + return { node: item, parent, list: tree, index: i }; + } + for (const key of Object.keys(item)) { + if (Array.isArray(item[key])) { + const res = findNodeAndParent(item[key], targetId, item, item[key]); + if (res) return res; + } + } + } + } + return null; +} + +function collectMenuLinksFromTree(tree: any): Array<{ url: string; label?: any; source: "payload" }> { + const links: Array<{ url: string; label?: any; source: "payload" }> = []; + const visited = new WeakSet(); + + const walk = (value: any) => { + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + + if (!value || typeof value !== "object") return; + if (visited.has(value)) return; + visited.add(value); + + if (!isStructuralMenuNode(value)) return; + + if (typeof value.url === "string" && value.url.trim()) { + links.push({ url: value.url, label: value.label ?? value.title, source: "payload" }); + } + + for (const key of MENU_NODE_ARRAY_KEYS) { + if (Array.isArray(value[key])) { + walk(value[key]); + } + } + }; + + walk(tree); + return links; +} + +function listStructuralKeys(node: any) { + return MENU_NODE_ARRAY_KEYS.filter((key) => Array.isArray(node?.[key])); +} + +function describeMenuNode(node: any) { + if (!isPlainObject(node)) return "invalid node"; + if (typeof node.url === "string" && node.url.trim()) return `link ${node.url}`; + const structuralKeys = listStructuralKeys(node); + if (structuralKeys.includes("columns")) return "column container"; + if (structuralKeys.length > 0) return `container (${structuralKeys.join(", ")})`; + return "menu node"; +} + +function validateMenuNodeData(nodeData: any, parentId?: string | null) { + const issues: string[] = []; + const warnings: string[] = []; + + if (!isPlainObject(nodeData)) { + issues.push("nodeData must be a plain object"); + return { issues, warnings }; + } + + const hasUrl = typeof nodeData.url === "string" && nodeData.url.trim(); + const hasLabel = nodeData.label !== undefined || nodeData.title !== undefined; + const structuralKeys = listStructuralKeys(nodeData); + + for (const key of ["label", "title"] as const) { + const value = nodeData[key]; + if (value !== undefined && !isPlainObject(value) && typeof value !== "string") { + issues.push(`${key} must be a string or localized object`); + } + } + + if (nodeData.url !== undefined && typeof nodeData.url !== "string") { + issues.push("url must be a string when provided"); + } + + for (const key of MENU_NODE_ARRAY_KEYS) { + if (nodeData[key] !== undefined && !Array.isArray(nodeData[key])) { + issues.push(`${key} must be an array when provided`); + } + } + + if (parentId === "root" && hasUrl && structuralKeys.length === 0) { + issues.push("cannot add a leaf link directly under root; create a column container first"); + } + + if (hasUrl && !hasLabel) { + warnings.push("link node does not include a label/title yet"); + } + + if (nodeData.translations !== undefined && !isPlainObject(nodeData.translations) && !Array.isArray(nodeData.translations)) { + issues.push("translations must be an object or array when provided"); + } + + return { issues, warnings }; +} + +function validateMenuTranslations(translations: any) { + const issues: string[] = []; + if (!isPlainObject(translations)) { + issues.push("translations must be a plain object"); + return { issues }; + } + for (const [key, value] of Object.entries(translations)) { + if (typeof value !== "string") { + issues.push(`translation for "${key}" must be a string`); + } + } + return { issues }; +} + +function validateMenuItems(items: any) { + const issues: string[] = []; + const warnings: string[] = []; + + if (!Array.isArray(items)) { + issues.push("items must be an array"); + return { issues, warnings }; + } + + items.forEach((item, index) => { + if (!isPlainObject(item)) { + issues.push(`items[${index}] must be a plain object`); + return; + } + + if (typeof item.url !== "string" || !item.url.trim()) { + issues.push(`items[${index}].url must be a non-empty string`); + } + + if (typeof item.label !== "string" || !item.label.trim()) { + warnings.push(`items[${index}] is missing a human-readable label`); + } + + if (item.order !== undefined && typeof item.order !== "number") { + issues.push(`items[${index}].order must be a number when provided`); + } + + if (item.translations !== undefined) { + if (!Array.isArray(item.translations)) { + issues.push(`items[${index}].translations must be an array when provided`); + } else { + item.translations.forEach((translation: any, translationIndex: number) => { + if (!isPlainObject(translation)) { + issues.push(`items[${index}].translations[${translationIndex}] must be a plain object`); + return; + } + if (typeof translation.langCode !== "string" || !translation.langCode.trim()) { + issues.push(`items[${index}].translations[${translationIndex}].langCode must be a non-empty string`); + } + if (typeof translation.label !== "string" || !translation.label.trim()) { + issues.push(`items[${index}].translations[${translationIndex}].label must be a non-empty string`); + } + }); + } + } + }); + + return { issues, warnings }; +} + +type MenuTreeMutationResult = { + payload: any[]; + warnings: string[]; + nodeId?: string; + removedNode?: string; +}; + +const menuSnapshots = new Map(); + +function ensureMenuPayloadArray(value: any) { + return Array.isArray(value) ? value : []; +} + +function validateMenuTreeSnapshot(payload: any[]) { + const issues: string[] = []; + const warnings: string[] = []; + const seenNodeIds = new Set(); + + const walk = (value: any, path: string) => { + if (Array.isArray(value)) { + value.forEach((child, index) => walk(child, `${path}[${index}]`)); + return; + } + + if (!isPlainObject(value)) return; + + if (!isStructuralMenuNode(value)) { + warnings.push(`${path}: non-structural object present in menu tree`); + return; + } + + if (value.nodeId && typeof value.nodeId === "string") { + if (seenNodeIds.has(value.nodeId)) { + issues.push(`${path}: duplicate nodeId ${value.nodeId}`); + } + seenNodeIds.add(value.nodeId); + } else { + warnings.push(`${path}: missing nodeId`); + } + + const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key])); + if (!hasChildren && !value.url) { + warnings.push(`${path}: container node has no children and no url`); + } + + for (const key of MENU_NODE_ARRAY_KEYS) { + if (Array.isArray(value[key])) { + walk(value[key], `${path}.${key}`); + } + } + }; + + walk(payload, "payload"); + return { issues, warnings }; +} + +function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult { + const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); + const nextNode = cloneMenuValue(nodeData); + const warnings: string[] = []; + + nextNode.nodeId = crypto.randomUUID(); + + if (nextNode.translations) { + const labelKey = nextNode.title !== undefined ? "title" : "label"; + if (typeof nextNode[labelKey] === "string") { + nextNode[labelKey] = { en: nextNode[labelKey] }; + } + nextNode[labelKey] = { ...(nextNode[labelKey] || {}), ...nextNode.translations }; + delete nextNode.translations; + } + + if (!parentId || parentId === "root") { + payload.push(nextNode); + } else { + const found = findNodeAndParent(payload, parentId); + if (!found) { + throw new Error(`Parent node ${parentId} not found`); + } + const children = getChildrenArray(found.node, found.parent === null); + children.push(nextNode); + } + + return { payload, warnings, nodeId: nextNode.nodeId }; +} + +function applyMenuUpdateTranslation(tree: any[], nodeId: string, translations: any): MenuTreeMutationResult { + const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); + const found = findNodeAndParent(payload, nodeId); + if (!found) throw new Error(`Node ${nodeId} not found`); + const labelKey = found.node.title !== undefined ? "title" : "label"; + found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations }; + return { payload, warnings: [], nodeId }; +} + +function applyMenuMoveNode(tree: any[], nodeId: string, newParentId?: string, orderIndex?: number): MenuTreeMutationResult { + const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); + const found = findNodeAndParent(payload, nodeId); + if (!found) throw new Error(`Node ${nodeId} not found`); + found.list.splice(found.index, 1); + + let targetList = payload; + if (newParentId && newParentId !== "root") { + const newParent = findNodeAndParent(payload, newParentId); + if (!newParent) throw new Error(`New parent ${newParentId} not found`); + targetList = getChildrenArray(newParent.node); + } + + if (typeof orderIndex === "number" && orderIndex >= 0) { + targetList.splice(orderIndex, 0, found.node); + } else { + targetList.push(found.node); + } + + return { payload, warnings: [], nodeId }; +} + +function applyMenuRemoveNode(tree: any[], nodeId: string): MenuTreeMutationResult { + const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); + const found = findNodeAndParent(payload, nodeId); + if (!found) throw new Error(`Node ${nodeId} not found`); + found.list.splice(found.index, 1); + return { payload, warnings: [], removedNode: describeMenuNode(found.node) }; +} + +function normalizeMenuPayloadForWrite(payload: any) { + const cloned = cloneMenuValue(ensureMenuPayloadArray(payload)); + let modified = false; + + const walk = (value: any) => { + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + + if (!isPlainObject(value)) return; + + if (!isStructuralMenuNode(value)) { + if (Object.prototype.hasOwnProperty.call(value, "nodeId")) { + delete value.nodeId; + modified = true; + } + return; + } + + if (!value.nodeId) { + value.nodeId = crypto.randomUUID(); + modified = true; + } + + for (const key of MENU_NODE_ARRAY_KEYS) { + if (Array.isArray(value[key])) { + walk(value[key]); + } + } + }; + + walk(cloned); + return { payload: cloned, modified }; +} + +function createMenuSnapshot(menu: any) { + const snapshotId = crypto.randomUUID(); + const payload = cloneMenuValue(ensureMenuPayloadArray(menu?.payload)); + const items = cloneMenuValue(Array.isArray(menu?.items) ? menu.items : []); + const snapshot = { + name: menu?.name || "main", + payload, + items, + isActive: menu?.isActive, + createdAt: new Date().toISOString(), + }; + menuSnapshots.set(snapshotId, snapshot); + return { snapshotId, ...snapshot }; +} + +function restoreMenuSnapshot(snapshotId: string) { + const snapshot = menuSnapshots.get(snapshotId); + if (!snapshot) { + throw new Error(`Snapshot ${snapshotId} not found`); + } + return cloneMenuValue(snapshot); +} + +function applyBulkMenuOperations(tree: any[], operations: any[]) { + let payload = cloneMenuValue(ensureMenuPayloadArray(tree)); + const warnings: string[] = []; + const applied: any[] = []; + + for (const [index, operation] of operations.entries()) { + if (!isPlainObject(operation)) { + throw new Error(`operations[${index}] must be a plain object`); + } + + let result: MenuTreeMutationResult; + + switch (operation.type) { + case "add_node": { + const validation = validateMenuNodeData(operation.nodeData, operation.parentId); + if (validation.issues.length > 0) { + throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`); + } + result = applyMenuAddNode(payload, operation.parentId, operation.nodeData); + warnings.push(...validation.warnings); + applied.push({ type: operation.type, nodeId: result.nodeId }); + break; + } + case "update_translation": { + const validation = validateMenuTranslations(operation.translations); + if (validation.issues.length > 0) { + throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`); + } + if (typeof operation.nodeId !== "string" || !operation.nodeId) { + throw new Error(`operations[${index}].nodeId must be a non-empty string`); + } + result = applyMenuUpdateTranslation(payload, operation.nodeId, operation.translations); + warnings.push(...validation.warnings); + applied.push({ type: operation.type, nodeId: result.nodeId }); + break; + } + case "move_node": { + if (typeof operation.nodeId !== "string" || !operation.nodeId) { + throw new Error(`operations[${index}].nodeId must be a non-empty string`); + } + result = applyMenuMoveNode(payload, operation.nodeId, operation.newParentId, operation.orderIndex); + applied.push({ type: operation.type, nodeId: result.nodeId }); + break; + } + case "remove_node": { + if (typeof operation.nodeId !== "string" || !operation.nodeId) { + throw new Error(`operations[${index}].nodeId must be a non-empty string`); + } + result = applyMenuRemoveNode(payload, operation.nodeId); + applied.push({ type: operation.type, removedNode: result.removedNode }); + break; + } + default: + throw new Error(`operations[${index}].type must be one of add_node, update_translation, move_node, remove_node`); + } + + payload = result.payload; + warnings.push(...result.warnings); + } + + const normalized = normalizeMenuPayloadForWrite(payload); + return { payload: normalized.payload, warnings, applied, normalized }; +} + +export { + MENU_NODE_ARRAY_KEYS, + MENU_NODE_VALUE_KEYS, + type MenuTreeMutationResult, + applyBulkMenuOperations, + applyMenuAddNode, + applyMenuMoveNode, + applyMenuRemoveNode, + applyMenuUpdateTranslation, + cloneMenuValue, + collectMenuLinksFromTree, + createMenuSnapshot, + describeMenuNode, + ensureMenuPayloadArray, + findNodeAndParent, + getChildrenArray, + isPlainObject, + isStructuralMenuNode, + normalizeMenuPayloadForWrite, + restoreMenuSnapshot, + validateMenuItems, + validateMenuNodeData, + validateMenuTranslations, + validateMenuTreeSnapshot, +}; diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts new file mode 100644 index 0000000..c8ec331 --- /dev/null +++ b/apps/mcp-server/section-toolkit.ts @@ -0,0 +1,316 @@ +import type { AxiosInstance } from "axios"; +import { PAGE_SECTION_TYPES } from "@homaroma/types"; + +const localizedStringSchema = { + oneOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: { type: "string" } + } + ] +}; + +const localizedStringListSchema = { + type: "array", + items: localizedStringSchema +}; + +const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }'; +const localizedStringListExample = `[ ${localizedStringExample} ]`; + +const SECTION_TOOL_CONFIGS = [ + { + name: "update_hero_article", + sectionType: "HERO_ARTICLE", + description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` }, + subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` }, + author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` }, + category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` }, + publishDate: { type: "string", description: "Plain string publish date or display date" }, + date: { type: "string", description: "Plain string alternative date field accepted by backend" }, + imageUrl: { type: "string", description: "Plain string main image URL" }, + backgroundImageUrl: { type: "string", description: "Plain string background image URL" }, + overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" } + }, + required: ["sectionId"] + }, + { + name: "update_rich_text", + sectionType: "RICH_TEXT", + description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` }, + content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` } + }, + required: ["sectionId", "content"] + }, + { + name: "update_scent_notes_breakdown", + sectionType: "SCENT_NOTES_BREAKDOWN", + description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, + description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` }, + topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` }, + heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` }, + middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` }, + baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` } + }, + required: ["sectionId"] + }, + { + name: "update_quote_callout", + sectionType: "QUOTE_CALLOUT", + description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` }, + author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` }, + role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` } + }, + required: ["sectionId"] + }, + { + name: "update_product_carousel", + sectionType: "PRODUCT_CAROUSEL", + description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, + description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` }, + collectionId: { type: "string", description: "Optional plain string collection identifier" }, + limit: { type: "number", description: "Maximum number of products to show" }, + showPrice: { type: "boolean", description: "Show product price" }, + showAddToCart: { type: "boolean", description: "Show add to cart button" }, + products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } } + }, + required: ["sectionId"] + }, + { + name: "update_accordion", + sectionType: "ACCORDION", + description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, + categories: { + type: "array", + description: "Accordion categories", + items: { + type: "object", + properties: { + name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` }, + icon: { type: "string", description: "Optional icon or emoji" }, + questions: { + type: "array", + description: "Questions under the category", + items: { + type: "object", + properties: { + q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` }, + a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` } + }, + required: ["q", "a"] + } + } + }, + required: ["name", "questions"] + } + } + }, + required: ["sectionId", "categories"] + }, + { + name: "update_contact_info", + sectionType: "CONTACT_INFO", + description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, + address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` }, + email: { type: "string", description: "Plain string email address" }, + line: { type: "string", description: "Plain string Line ID or contact" }, + hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` } + }, + required: ["sectionId", "address", "email", "line", "hours"] + }, + { + name: "update_contact_form", + sectionType: "CONTACT_FORM", + description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` }, + subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` } + }, + required: ["sectionId"] + } +] as const; + +type SectionToolConfig = (typeof SECTION_TOOL_CONFIGS)[number]; + +const SECTION_TOOL_CONFIG_BY_NAME = new Map( + SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const) +); + +function buildSectionPayload(params: any, fields: readonly string[]) { + const payload: Record = {}; + for (const field of fields) { + if (params[field] !== undefined) { + payload[field] = params[field]; + } + } + return payload; +} + +function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record) { + const notes: string[] = []; + if (sectionType === "SCENT_NOTES_BREAKDOWN") { + notes.push("heartNotes and middleNotes are mirrored automatically by the backend"); + } + if (sectionType === "PRODUCT_CAROUSEL") { + notes.push("If products was omitted, the backend may populate products on read-back"); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: true, + tool: toolName, + sectionType, + sectionId: updatedSection?.id, + updatedFields: Object.keys(payload), + appliedPayload: payload, + notes, + section: updatedSection + }, + null, + 2 + ) + } + ] + }; +} + +export const SECTION_TOOL_DEFINITIONS = [ + { + name: "add_page_section", + description: "Add a block section to a dynamic page layout", + inputSchema: { + type: "object", + properties: { + pageId: { type: "number", description: "The numeric ID of the page" }, + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" }, + order: { type: "number", description: "Display order sequence (starts at 1)" }, + payload: { type: "object", description: "The JSON payload matching the section type rules" }, + isActive: { type: "boolean", description: "Initial active state" } + }, + required: ["pageId", "type", "payload"] + } + }, + { + name: "update_page_section", + description: "Update an existing block section payload, order or active state", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional new block type" }, + order: { type: "number", description: "Optional new display order sequence" }, + payload: { type: "object", description: "Optional new JSON payload matching the section type rules" }, + isActive: { type: "boolean", description: "Optional new active state" } + }, + required: ["sectionId"] + } + }, + ...SECTION_TOOL_CONFIGS.map((config) => ({ + name: config.name, + description: config.description, + inputSchema: { + type: "object", + properties: config.properties, + required: config.required + } + })), + { + name: "delete_page_section", + description: "Delete a block section from a dynamic page layout", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to delete" } + }, + required: ["sectionId"] + } + } +] as const; + +export const SECTION_TOOL_NAMES = new Set(SECTION_TOOL_DEFINITIONS.map((tool) => tool.name)); + +export async function handleSectionTool(api: AxiosInstance, name: string, params: any) { + if (!SECTION_TOOL_NAMES.has(name)) return null; + + switch (name) { + case "add_page_section": { + const payload: any = { + type: params.type, + payload: params.payload, + isActive: params.isActive !== false + }; + if (params.order !== undefined) payload.order = params.order; + const res = await api.post(`/pages/${params.pageId}/sections`, payload); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "update_page_section": { + const payload: any = {}; + if (params.type) payload.type = params.type; + if (params.order !== undefined) payload.order = params.order; + if (params.payload) payload.payload = params.payload; + if (params.isActive !== undefined) payload.isActive = params.isActive; + const res = await api.put(`/pages/sections/${params.sectionId}`, payload); + return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload); + } + case "update_hero_article": + case "update_rich_text": + case "update_scent_notes_breakdown": + case "update_quote_callout": + case "update_product_carousel": + case "update_accordion": + case "update_contact_info": + case "update_contact_form": { + const config = SECTION_TOOL_CONFIG_BY_NAME.get(name); + if (!config) { + throw new Error(`Unknown tool config for: ${name}`); + } + + const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId")); + if (config.sectionType === "SCENT_NOTES_BREAKDOWN") { + if (payload.heartNotes !== undefined && payload.middleNotes === undefined) { + payload.middleNotes = payload.heartNotes; + } else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) { + payload.heartNotes = payload.middleNotes; + } + } + const res = await api.put(`/pages/sections/${params.sectionId}`, { + type: config.sectionType, + ...payload + }); + + return buildSectionUpdateResult(name, config.sectionType, res.data, payload); + } + case "delete_page_section": { + const res = await api.delete(`/pages/sections/${params.sectionId}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + default: + return null; + } +} diff --git a/apps/spa/src/components/Footer.tsx b/apps/spa/src/components/Footer.tsx index acc9990..4286691 100644 --- a/apps/spa/src/components/Footer.tsx +++ b/apps/spa/src/components/Footer.tsx @@ -65,7 +65,7 @@ export default function Footer({ categories = [] }: FooterProps) {
- {brandName} Essential + {brandName}

{settings?.footerDescription || t('footer.catchphrase')} diff --git a/apps/spa/src/components/Navbar.tsx b/apps/spa/src/components/Navbar.tsx index 2ef542a..6d43d0f 100644 --- a/apps/spa/src/components/Navbar.tsx +++ b/apps/spa/src/components/Navbar.tsx @@ -138,7 +138,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) { {/* Left: Logo */}

setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0"> - {brandName} Essential + {brandName}