import crypto from "crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } 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; } let BACKEND_URL = "http://localhost:3051/api"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin"; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123"; // เปลี่ยนรหัสผ่านตรงนี้ให้ตรงกับ Production ด้วยนะครับ let authToken = ""; export const api = axios.create({ baseURL: BACKEND_URL, }); api.interceptors.request.use(async (config) => { if (!authToken && config.url !== "/auth/login") { console.error("[MCP] No token found. Attempting auto-login..."); await performLogin(); } if (authToken) { config.headers.Authorization = `Bearer ${authToken}`; } return config; }); async function performLogin() { try { const res = await axios.post(`${BACKEND_URL}/auth/login`, { username: ADMIN_USERNAME, password: ADMIN_PASSWORD, }); if (res.data && res.data.token) { authToken = res.data.token; console.error("[MCP] Successfully logged in to Backend API."); } } catch (err: any) { console.error("[MCP] Auto-login failed:", err.message); } } const server = new Server( { name: "homaroma-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "set_target_environment", description: "Switch the target environment for the MCP server (local or production)", inputSchema: { type: "object", properties: { env: { type: "string", enum: ["local", "production"] } }, 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"] } }, { name: "ping_backend", description: "Ping the backend to verify connection and authentication", inputSchema: { type: "object", properties: {} }, }, { name: "search_products", description: "Search for products and get their basic info", inputSchema: { type: "object", properties: { search: { type: "string", description: "Search term" }, limit: { type: "number", description: "Max results" }, lang: { type: "string", description: "Language code (e.g. th, en, all)" } } } }, { name: "get_product_detail", description: "Get full product details including variants, images, and category", inputSchema: { type: "object", properties: { id: { type: "number", description: "Product ID" }, lang: { type: "string", description: "Language code (e.g. th, en, all)" } }, required: ["id"] } }, { name: "update_category_translation", description: "Add or update translations for a specific category. Do NOT use this tool for products.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Category ID" }, lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" }, name: { type: "string", description: "Translated category name" } }, required: ["id", "lang", "name"] } }, { name: "update_scent_translation", description: "Add or update translations for a specific scent.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Scent ID" }, lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" }, name: { type: "string", description: "Translated scent name" } }, required: ["id", "lang", "name"] } }, { name: "update_page_translation", description: "Add or update translations for a specific page.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Page ID" }, lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" }, title: { type: "string", description: "Translated page title" } }, required: ["id", "lang", "title"] } }, { name: "update_hero_translation", description: "Add or update translations for a specific hero.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Hero ID" }, lang: { type: "string", description: "Language code" }, tagline: { type: "string" }, title: { type: "string" }, description: { type: "string" }, buttonText: { type: "string" } }, required: ["id", "lang"] } }, { name: "update_menu_item_translation", description: "Add or update translations for a specific menu item.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Menu Item ID" }, lang: { type: "string", description: "Language code" }, label: { type: "string", description: "Translated menu item label" } }, required: ["id", "lang", "label"] } }, { name: "update_store_settings_translation", description: "Add or update translations for store settings.", inputSchema: { type: "object", properties: { lang: { type: "string", description: "Language code" }, footerDescription: { type: "string" }, newsletterTitle: { type: "string" }, newsletterDescription: { type: "string" }, newsletterPlaceholder: { type: "string" }, newsletterButtonText: { type: "string" } }, required: ["lang"] } }, { name: "update_article_translation", description: "Add or update translations for a specific article.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Article ID" }, lang: { type: "string", description: "Language code" }, title: { type: "string" }, content: { type: "string" } }, required: ["id", "lang"] } }, { name: "update_product_content", description: "Update product basic content (name, description, details)", inputSchema: { type: "object", properties: { id: { type: "number" }, name: { type: "string" }, description: { type: "string" }, details: { type: "string" }, }, required: ["id"] } }, { name: "update_product_translation", description: "Update product translation for a specific language. Use this to add missing translations.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Product ID" }, lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" }, name: { type: "string", description: "Translated product name" }, description: { type: "string", description: "Translated description" }, details: { type: "string", description: "Translated details (HTML or Markdown allowed)" } }, required: ["id", "lang"] } }, { name: "upload_product_image_from_url", description: "Download image from URL, upload to backend, and append to product images", inputSchema: { type: "object", properties: { productId: { type: "number" }, imageUrl: { type: "string", description: "URL of the image to download" } }, required: ["productId", "imageUrl"] } }, { name: "generate_mock_review", description: "Create a dummy review for a product", inputSchema: { type: "object", properties: { productId: { type: "number" }, rating: { type: "number", description: "1-5" }, comment: { type: "string" }, customerName: { type: "string", description: "Name of the mock reviewer" } }, required: ["productId", "rating", "customerName"] } }, { name: "get_order_status", description: "Get the status and details of a specific order", inputSchema: { type: "object", properties: { orderId: { type: "number" } }, required: ["orderId"] } }, { name: "list_pending_orders", description: "List orders that are pending payment or processing", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Max results" } } } }, { name: "update_order_tracking", description: "Update the tracking number of an order and optionally its status", inputSchema: { type: "object", properties: { orderId: { type: "number" }, trackingNo: { type: "string" }, status: { type: "string", description: "Optional status to update (e.g. SHIPPED)" } }, required: ["orderId", "trackingNo"] } }, { name: "search_customer", description: "Search for customers by name, email, phone, or line ID", inputSchema: { type: "object", properties: { search: { type: "string", description: "Search term" } } } }, { name: "list_dynamic_pages", description: "List all dynamic pages and layouts managed by the CMS", inputSchema: { type: "object", properties: {} } }, { name: "list_dynamic_pages_metadata", description: "List all dynamic pages and layouts managed by the CMS (metadata only)", inputSchema: { type: "object", properties: {} } }, { name: "get_dynamic_page_detail", description: "Get full detail of a specific dynamic page by slug", inputSchema: { type: "object", properties: { slug: { type: "string", description: "Slug of the page" } }, required: ["slug"] } }, { name: "create_dynamic_page", description: "Create or update a dynamic page or layout", inputSchema: { type: "object", properties: { title: { type: "string", description: "Title of the page" }, slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" }, isPublished: { type: "boolean", description: "Publish status" }, status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" }, metadata: { type: "object", description: "SEO metadata" }, id: { type: "number", description: "Numeric page ID (if updating existing page)" } }, 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"] } }, { 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"] } }, { name: "update_footer_content", description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)", inputSchema: { type: "object", properties: { brandDescription: { type: "string", description: "Text for Block 1 (Brand tagline)" }, socialLinks: { type: "object", description: "URLs for facebook, instagram, tiktok (e.g. { facebookUrl: '...' })", properties: { facebookUrl: { type: "string" }, instagramUrl: { type: "string" }, tiktokUrl: { type: "string" } } }, exploreLinks: { type: "array", description: "Array of { label, url, order } for Block 2 (Explore)", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } } }, collectionLinks: { type: "array", description: "Array of { label, url, order } for Block 3 (Collections)", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } } }, supportLinks: { type: "array", description: "Array of { label, url, order } for Block 4 (Support/Legal)", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } } } } } }, { 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"] } } ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const params = request.params.arguments as any; try { switch (request.params.name) { case "set_target_environment": { const env = params.env; if (env === "production") { BACKEND_URL = "https://api.homaroma.shop/api"; } else { BACKEND_URL = "http://localhost:3051/api"; } api.defaults.baseURL = BACKEND_URL; authToken = ""; // reset token so it logs in again on next request return { content: [ { type: "text", text: `✅ MCP Server target environment switched to [${env}]. Base URL is now: ${BACKEND_URL}` } ] }; } case "ping_backend": { const res = await api.get("/products?limit=1"); return { content: [{ type: "text", text: `Connection successful!` }] }; } 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 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"; const lang = params.lang ? `&lang=${params.lang}` : ""; const res = await api.get(`/products?${search}${limit}${lang}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "get_product_detail": { const lang = params.lang ? `?lang=${params.lang}` : ""; const res = await api.get(`/products/${params.id}${lang}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_category_translation": { const { id, lang, name } = params as any; const res = await api.patch(`/categories/${id}/translations`, { name: { [lang]: name } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_scent_translation": { const { id, lang, name } = params as any; const res = await api.patch(`/products/scents/${id}/translations`, { name: { [lang]: name } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_page_translation": { const { id, lang, title } = params as any; const res = await api.patch(`/pages/${id}/translations`, { title: { [lang]: title } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_hero_translation": { const { id, lang, tagline, title, description, buttonText } = params as any; const payload: any = {}; if (tagline) payload.tagline = { [lang]: tagline }; if (title) payload.title = { [lang]: title }; if (description) payload.description = { [lang]: description }; if (buttonText) payload.buttonText = { [lang]: buttonText }; const res = await api.patch(`/hero/${id}/translations`, payload); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_menu_item_translation": { const { id, lang, label } = params as any; const res = await api.patch(`/menus/items/${id}/translations`, { label: { [lang]: label } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_store_settings_translation": { const { lang, footerDescription, newsletterTitle, newsletterDescription, newsletterPlaceholder, newsletterButtonText } = params as any; const payload: any = {}; if (footerDescription) payload.footerDescription = { [lang]: footerDescription }; if (newsletterTitle) payload.newsletterTitle = { [lang]: newsletterTitle }; if (newsletterDescription) payload.newsletterDescription = { [lang]: newsletterDescription }; if (newsletterPlaceholder) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder }; if (newsletterButtonText) payload.newsletterButtonText = { [lang]: newsletterButtonText }; const res = await api.patch(`/settings/translations`, payload); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_article_translation": { const { id, lang, title, content } = params as any; const payload: any = {}; if (title) payload.title = { [lang]: title }; if (content) payload.content = { [lang]: content }; const res = await api.patch(`/articles/${id}/translations`, payload); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_product_content": { const payload: any = {}; if (params.name) payload.name = params.name; if (params.description) payload.description = params.description; if (params.details) payload.details = params.details; const res = await api.patch(`/products/${params.id}`, payload); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "update_product_translation": { const payload: any = {}; if (params.name) payload.name = { [params.lang]: params.name }; if (params.description) payload.description = { [params.lang]: params.description }; if (params.details) payload.details = { [params.lang]: params.details }; const res = await api.patch(`/products/${params.id}`, payload); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "upload_product_image_from_url": { // 1. Download image const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' }); const buffer = Buffer.from(imgRes.data, 'binary'); const ext = params.imageUrl.split('.').pop()?.split('?')[0] || 'jpg'; // 2. Upload to backend const formData = new FormData(); formData.append('file', buffer, { filename: `download.${ext}` }); const uploadRes = await api.post('/upload', formData, { headers: formData.getHeaders() }); const uploadedUrl = uploadRes.data.url; // 3. Get current product to append image const prodRes = await api.get(`/products/${params.productId}`); const product = prodRes.data; const newImages = [...product.images, { url: uploadedUrl, order: product.images.length }]; // 4. Update product (using PUT to sync variants and images) // Ensure we pass required fields const putRes = await api.put(`/products/${params.productId}`, { categoryId: product.categoryId, description: product.description, details: product.details, variants: product.variants, images: newImages }); return { content: [{ type: "text", text: `Image uploaded and product updated. New image URL: ${uploadedUrl}` }] }; } case "generate_mock_review": { const res = await api.post("/reviews", { productId: params.productId, rating: params.rating, comment: params.comment, customerName: params.customerName }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "get_order_status": { const res = await api.get(`/orders/${params.orderId}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "list_pending_orders": { const res = await api.get(`/orders`); const pending = res.data.filter((o: any) => o.status === "PENDING_PAYMENT" || o.status === "PENDING" || o.status === "PROCESSING"); if (params.limit) { pending.splice(params.limit); } return { content: [{ type: "text", text: JSON.stringify(pending, null, 2) }] }; } case "update_order_tracking": { const payload: any = { trackingNo: params.trackingNo }; const res1 = await api.patch(`/orders/${params.orderId}/tracking`, payload); let result = res1.data; if (params.status) { const res2 = await api.patch(`/orders/${params.orderId}/status`, { status: params.status }); result = res2.data; } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } case "search_customer": { const search = params.search ? `?search=${encodeURIComponent(params.search)}` : ""; const res = await api.get(`/customers${search}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "list_dynamic_pages": { const res = await api.get("/pages"); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "list_dynamic_pages_metadata": { const res = await api.get("/pages"); let metadata = res.data; if (Array.isArray(metadata)) { metadata = metadata.map((p: any) => ({ id: p.id, slug: p.slug, title: p.title })); } else if (metadata && Array.isArray(metadata.data)) { metadata = { ...metadata, data: metadata.data.map((p: any) => ({ id: p.id, slug: p.slug, title: p.title })) }; } return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] }; } case "get_dynamic_page_detail": { const res = await api.get(`/pages/${params.slug}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "create_dynamic_page": { const payload: any = { title: params.title, slug: params.slug, }; if (params.isPublished !== undefined) payload.isPublished = params.isPublished; if (params.status !== undefined) payload.status = params.status; if (params.metadata !== undefined) payload.metadata = params.metadata; if (params.id) { payload.id = params.id; } const res = await api.post("/pages", payload); return { content: [{ type: "text", text: JSON.stringify(res.data, 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 { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } 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 = []; if (params.brandDescription !== undefined || params.socialLinks) { const settingsPayload: any = {}; if (params.brandDescription !== undefined) settingsPayload.footerDescription = params.brandDescription; if (params.socialLinks?.facebookUrl !== undefined) settingsPayload.facebookUrl = params.socialLinks.facebookUrl; if (params.socialLinks?.instagramUrl !== undefined) settingsPayload.instagramUrl = params.socialLinks.instagramUrl; if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl; if (Object.keys(settingsPayload).length > 0) { // First fetch existing settings because the backend requires full payload validation const currentSettingsRes = await api.get("/settings"); const currentSettings = currentSettingsRes.data || {}; const mergedSettings = { ...currentSettings, ...settingsPayload }; // Remove null values so Elysia validation doesn't fail on t.Optional(t.String()) for (const key in mergedSettings) { if (mergedSettings[key] === null) { delete mergedSettings[key]; } } await api.put("/settings", mergedSettings); results.push({ type: "settings", status: "success" }); } } if (params.exploreLinks) { await api.put("/menus/footer-explore", { items: params.exploreLinks }); results.push({ type: "explore_links", status: "success" }); } if (params.collectionLinks) { await api.put("/menus/footer-collections", { items: params.collectionLinks }); results.push({ type: "collection_links", status: "success" }); } if (params.supportLinks) { await api.put("/menus/footer-support", { items: params.supportLinks }); results.push({ type: "support_links", status: "success" }); } 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}`); } } catch (err: any) { const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message; return { content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }], isError: true, }; } }); async function main() { await performLogin(); const transport = new StdioServerTransport(); await server.connect(transport); console.error("HomAroma MCP Server running on stdio"); } main().catch(console.error);