Files
homaroma/apps/mcp-server/index.ts
T
2026-07-10 20:27:26 -04:00

1271 lines
52 KiB
TypeScript

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 { 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";
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
let authToken = "";
export const api = axios.create({
baseURL: BACKEND_URL,
});
// MCP server state is process-local because this server runs over stdio.
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"]
}
},
...MENU_TOOL_DEFINITIONS,
...SECTION_TOOL_DEFINITIONS,
{
name: "ping_backend",
description: "Ping the backend to verify connection and authentication",
inputSchema: { type: "object", properties: {} },
},
{
name: "list_product_brands",
description: "List all product brands with their translations",
inputSchema: { type: "object", properties: {} }
},
{
name: "list_categories",
description: "List all product categories with their translations",
inputSchema: { type: "object", properties: {} }
},
{
name: "search_products",
description: "Search for products and get their basic info. WARNING: Set a low 'limit' (e.g. 5) to prevent token overflow, as product details are massive.",
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)" },
brandId: { type: "number", description: "Filter by brand ID" }
}
}
},
{
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_category_image",
description: "Update the image URL for a specific category.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
imageUrl: { type: ["string", "null"], description: "New image URL, or null/empty string to remove the image" }
},
required: ["id", "imageUrl"]
}
},
{
name: "update_product_brand_translation",
description: "Add or update translations for a specific product brand.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Brand ID" },
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated brand name" }
},
required: ["id", "lang", "name"]
}
},
{
name: "update_page_translation",
description: "Add or update one page title translation for a specific page. Use list_dynamic_pages_metadata first to discover the numeric page ID.",
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_page_translations",
description: "Add or update multiple page title translations in one call. Use list_dynamic_pages_metadata first to discover the numeric page ID.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Page ID" },
translations: {
type: "object",
description: "Language-code map for the page title, for example { \"th\": \"...\", \"en\": \"...\" }. Each key becomes or updates one locale."
}
},
required: ["id", "translations"]
}
},
{
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: "Update translated footer and newsletter text for a specific language.",
inputSchema: {
type: "object",
properties: {
lang: { type: "string" },
footerDescription: { type: "string" },
newsletterTitle: { type: "string" },
newsletterDescription: { type: "string" },
newsletterPlaceholder: { type: "string" },
newsletterButtonText: { type: "string" }
},
required: ["lang"]
}
},
{
name: "update_theme_tokens",
description: "Update the theme tokens (colors, fonts, shadows) in the store settings.",
inputSchema: {
type: "object",
properties: {
brandPrimary: { type: "string" },
brandPrimaryHover: { type: "string" },
brandAccent: { type: "string" },
brandWarm: { type: "string" },
brandCool: { type: "string" },
brandSuccess: { type: "string" },
brandSoft: { type: "string" },
surfacePage: { type: "string" },
surfaceNavRest: { type: "string" },
surfaceCard: { type: "string" },
textMain: { type: "string" },
mutedText: { type: "string" },
linePrimary: { type: "string" },
linePrimaryHover: { type: "string" },
fontBody: { type: "string" },
fontDisplay: { type: "string" },
shadowCard: { type: "string" },
shadowCardHover: { type: "string" },
captionText: { type: "string", description: "Tertiary/caption text color (e.g. #B0A396)" },
borderColor: { type: "string", description: "Default border/divider color (e.g. #E8E0D8)" },
surfaceMuted: { type: "string", description: "Muted surface background for footer/section bands (e.g. #EDE6DD)" },
radiusBase: { type: "string", description: "Base border-radius for UI components (e.g. 8px)" },
radiusCard: { type: "string", description: "Border-radius for cards (e.g. 16px)" }
}
}
},
{
name: "update_brand_name",
description: "Update the brand name used across website and SPA headers, footers, and brand displays.",
inputSchema: {
type: "object",
properties: {
brandName: { type: "string", description: "Brand display name to store in system settings" }
},
required: ["brandName"]
}
},
{
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: "create_product",
description: "Create a new product, optionally including its options and variant matrix. Provide categoryId/brandId OR categoryName/brandName.",
inputSchema: {
type: "object",
properties: {
categoryId: { type: "number" },
categoryName: { type: "string" },
brandId: { type: "number" },
brandName: { type: "string" },
name: { type: "string" },
description: { type: "string" },
details: { type: "string" },
translations: {
type: "object",
description: "Object with lang codes as keys (e.g. 'th', 'en') and {name, description, details} as values",
additionalProperties: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
details: { type: "string" }
}
}
},
options: {
type: "array",
description: "Array of option definitions (max 3). E.g., [{'name': {'th': 'ขนาด'}, 'values': [{'th': 'เล็ก'}, {'en': 'Small'}]}]",
items: {
type: "object",
properties: {
name: {
type: "object",
description: "Object with lang codes as keys, e.g. {'th': 'ขนาด', 'en': 'Size'}",
additionalProperties: { type: "string" }
},
values: {
type: "array",
items: {
type: "object",
description: "Object with lang codes as keys",
additionalProperties: { type: "string" }
}
}
},
required: ["name", "values"]
}
},
variants: {
type: "array",
description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
sku: { type: "string" },
barcode: { type: "string" },
price: { type: "number" },
stock: { type: "number", default: 0 },
option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
}
}
}
},
{
name: "bulk_create_products",
description: "Create multiple products at once. Each object in the array has the same schema as create_product.",
inputSchema: {
type: "object",
properties: {
products: {
type: "array",
items: {
type: "object",
properties: {
categoryId: { type: "number" },
categoryName: { type: "string" },
brandId: { type: "number" },
brandName: { type: "string" },
name: { type: "string" },
description: { type: "string" },
details: { type: "string" },
translations: {
type: "object",
additionalProperties: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
details: { type: "string" }
}
}
},
options: {
type: "array",
items: {
type: "object",
properties: {
name: {
type: "object",
additionalProperties: { type: "string" }
},
values: {
type: "array",
items: {
type: "object",
additionalProperties: { type: "string" }
}
}
},
required: ["name", "values"]
}
},
variants: {
type: "array",
description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
sku: { type: "string" },
barcode: { type: "string" },
price: { type: "number" },
stock: { type: "number", default: 0 },
option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
}
}
}
}
},
required: ["products"]
}
},
{
name: "sync_product_variants",
description: "Overwrite and synchronize the options and variant matrix for an existing product. The provided variants will replace or update existing ones based on option combinations.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
options: {
type: "array",
description: "The new or updated options array (max 3).",
items: {
type: "object",
properties: {
name: {
type: "object",
description: "Object with language codes as keys (e.g. {'th': 'ขนาด', 'en': 'Size'})",
additionalProperties: { type: "string" }
},
values: {
type: "array",
description: "Array of language map objects (e.g. [{'th': 'เล็ก', 'en': 'Small'}, {'th': 'ใหญ่', 'en': 'Large'}])",
items: {
type: "object",
additionalProperties: { type: "string" }
}
}
},
required: ["name", "values"]
}
},
variants: {
type: "array",
description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
id: { type: "number", description: "Include if updating an existing variant" },
sku: { type: "string" },
price: { type: "number" },
stock: { type: "number" },
option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values (e.g. 'เล็ก'). Do NOT use translated strings." },
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
}
},
required: ["productId", "options", "variants"]
}
},
{
name: "update_product_content",
description: "Update product basic content for the default/Thai translation. Note: If the Thai translation record does not exist yet, the 'name' field is strictly required, otherwise the backend will silently drop the update.",
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. Note: If adding a missing translation, the 'name' field is strictly required by the backend, otherwise the update will silently fail.",
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)" },
options: {
type: "array",
description: "Translated options mapped by index. Provide array of { name: 'Translated Option', values: ['Trans Val 1', 'Trans Val 2'] }. Do not use for 'th', use sync_product_variants to edit base Thai options.",
items: {
type: "object",
properties: {
name: { type: "string" },
values: { type: "array", items: { type: "string" } }
},
required: ["name", "values"]
}
}
},
required: ["id", "lang"]
}
},
{
name: "list_attribute_definitions",
description: "List attribute definitions, optionally filtered by category. Returns localized labels unless lang=all.",
inputSchema: {
type: "object",
properties: {
categoryId: { type: "number" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
}
}
},
{
name: "get_attribute_definition_detail",
description: "Get full detail for one attribute definition, including translations and localized option labels.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
},
required: ["id"]
}
},
{
name: "create_attribute_definition",
description: "Create a reusable product attribute definition. For SELECT, provide localized options as arrays of language maps.",
inputSchema: {
type: "object",
properties: {
code: { type: "string" },
type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
label: { type: "string", description: "Base Thai or fallback label" },
description: { type: "string" },
unit: { type: "string" },
isRequired: { type: "boolean" },
isFilterable: { type: "boolean" },
sortOrder: { type: "number" },
categoryId: { type: "number" },
translations: {
type: "object",
additionalProperties: {
type: "object",
properties: {
label: { type: "string" },
description: { type: "string" }
}
}
},
options: {
type: "array",
items: {
type: "object",
additionalProperties: { type: "string" }
}
}
},
required: ["code", "type", "label"]
}
},
{
name: "update_attribute_definition",
description: "Update base metadata for an attribute definition.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
code: { type: "string" },
type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
label: { type: "string" },
description: { type: "string" },
unit: { type: "string" },
isRequired: { type: "boolean" },
isFilterable: { type: "boolean" },
sortOrder: { type: "number" },
categoryId: { type: "number" },
options: {
type: "array",
items: {
type: "object",
additionalProperties: { type: "string" }
}
}
},
required: ["id", "code", "type", "label"]
}
},
{
name: "update_attribute_definition_translation",
description: "Upsert translated label/description for an attribute definition.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
lang: { type: "string" },
label: { type: "string" },
description: { type: "string" }
},
required: ["id", "lang", "label"]
}
},
{
name: "list_product_attributes",
description: "List localized product attributes for a product.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
},
required: ["productId"]
}
},
{
name: "set_product_attribute",
description: "Create or update a product attribute base value. Only TEXT attributes support translations separately. SELECT values must match the base Thai option value.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
attributeDefinitionId: { type: "number" },
valueText: { type: "string" },
valueNumber: { type: "number" },
valueBoolean: { type: "boolean" },
valueJson: { type: "object" }
},
required: ["productId", "attributeDefinitionId"]
}
},
{
name: "update_product_attribute_translation",
description: "Upsert translated valueText for a TEXT product attribute only. Fallback is requested lang -> th -> base valueText.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
productAttributeId: { type: "number" },
lang: { type: "string" },
valueText: { type: "string" }
},
required: ["productId", "productAttributeId", "lang", "valueText"]
}
},
{
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 in a lightweight discovery format. Use get_dynamic_page_detail for full section payloads.",
inputSchema: { type: "object", properties: {} }
},
{
name: "list_dynamic_pages_metadata",
description: "List all dynamic pages and layouts managed by the CMS in the smallest discovery format (id, slug, title only).",
inputSchema: { type: "object", properties: {} }
},
{
name: "get_dynamic_page_detail",
description: "Get full detail of a specific dynamic page by slug. By default only active sections are returned; set includeInactive=true to include inactive sections.",
inputSchema: {
type: "object",
properties: {
slug: { type: "string", description: "Slug of the page" },
includeInactive: { type: "boolean", description: "Include inactive sections" }
},
required: ["slug"]
}
},
{
name: "create_dynamic_page",
description: "Create or update a dynamic page or layout. Use isPublished as the only publish field. If omitted on create, it defaults to false. If omitted on update, the existing publish state is preserved. The response includes publishBehavior to make this explicit. Page-level metadata is not supported by the current Page model.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Title of the page" },
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." },
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
},
required: ["title", "slug"]
}
},
{
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" } }
}
}
}
}
},
],
};
});
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;
if (env === "production") {
BACKEND_URL = PRODUCTION_BACKEND_URL;
} else {
BACKEND_URL = LOCAL_BACKEND_URL;
}
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 "list_product_brands": {
const res = await api.get(`/products/brands`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_categories": {
const res = await api.get(`/categories`);
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 brandId = params.brandId ? `&brandId=${params.brandId}` : "";
const res = await api.get(`/products?${search}${limit}${lang}${brandId}`);
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 "create_product": {
const res = await api.post(`/products`, params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "bulk_create_products": {
const res = await api.post(`/products/bulk`, params.products);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "sync_product_variants": {
const payload = {
options: params.options,
variants: params.variants
};
const res = await api.put(`/products/${params.productId}`, payload);
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_category_image": {
const { id, imageUrl } = params as any;
const res = await api.patch(`/categories/${id}/image`, {
imageUrl: imageUrl || null
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
};
}
case "update_product_brand_translation": {
const { id, lang, name } = params as any;
const res = await api.patch(`/products/brands/${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_page_translations": {
const { id, translations } = params as any;
const res = await api.patch(`/pages/${id}/translations`, {
title: translations
});
return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? 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_theme_tokens": {
const tokenParams = params as Record<string, string>;
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const currentTokens = currentSettings.themeTokens || {};
const newTokens = { ...currentTokens };
const validKeys = [
"brandPrimary", "brandPrimaryHover", "brandAccent", "brandWarm",
"brandCool", "brandSuccess", "brandSoft", "surfacePage",
"surfaceNavRest", "surfaceCard", "textMain", "mutedText",
"linePrimary", "linePrimaryHover", "fontBody", "fontDisplay",
"shadowCard", "shadowCardHover",
// Extended Quiet Luxury tokens
"captionText", "borderColor", "surfaceMuted", "radiusBase", "radiusCard"
];
for (const key of validKeys) {
if (tokenParams[key] !== undefined) {
newTokens[key] = tokenParams[key];
}
}
const mergedSettings = { ...currentSettings, themeTokens: newTokens };
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
const res = await api.put("/settings", mergedSettings);
return { content: [{ type: "text", text: "Theme tokens updated successfully:\\n" + JSON.stringify(res.data.themeTokens, null, 2) }] };
}
case "update_brand_name": {
const { brandName } = params as any;
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const mergedSettings = { ...currentSettings, brandName };
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
const res = await api.put("/settings", mergedSettings);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
tool: "update_brand_name",
brandName: res.data?.brandName ?? brandName,
settings: 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;
await api.patch(`/products/${params.id}`, payload);
return {
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Product ${params.id} updated successfully.` }, 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 };
if (params.options && params.lang !== "th") {
const productRes = await api.get(`/products/${params.id}`);
const product = productRes.data;
if (product && Array.isArray(product.options)) {
const mergedOptions = product.options.map((opt: any, i: number) => {
const newOpt = params.options[i];
if (!newOpt) return opt;
const mergedName = { ...opt.name, [params.lang]: newOpt.name };
const mergedValues = Array.isArray(opt.values) ? opt.values.map((val: any, j: number) => {
const newVal = newOpt.values?.[j];
return newVal ? { ...val, [params.lang]: newVal } : val;
}) : opt.values;
return { ...opt, name: mergedName, values: mergedValues };
});
payload.options = mergedOptions;
}
}
await api.patch(`/products/${params.id}`, payload);
return {
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
};
}
case "list_attribute_definitions": {
const categoryId = params.categoryId ? `categoryId=${params.categoryId}&` : "";
const lang = params.lang ? `lang=${params.lang}` : "lang=th";
const res = await api.get(`/product-attributes/definitions?${categoryId}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_attribute_definition_detail": {
const lang = params.lang ? `?lang=${params.lang}` : "";
const res = await api.get(`/product-attributes/definitions/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_attribute_definition": {
const res = await api.post(`/product-attributes/definitions`, params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_attribute_definition": {
const { id, ...payload } = params as any;
const res = await api.put(`/product-attributes/definitions/${id}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_attribute_definition_translation": {
const { id, lang, label, description } = params as any;
const res = await api.patch(`/product-attributes/definitions/${id}/translations`, {
translations: {
[lang]: { label, description }
}
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_product_attributes": {
const lang = params.lang ? `?lang=${params.lang}` : "";
const res = await api.get(`/products/${params.productId}/attributes${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "set_product_attribute": {
const { productId, ...payload } = params as any;
const res = await api.post(`/products/${productId}/attributes`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_product_attribute_translation": {
const { productId, productAttributeId, lang, valueText } = params as any;
const res = await api.patch(`/products/${productId}/attributes/${productAttributeId}/translations`, {
translations: {
[lang]: valueText
}
});
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. Post the new image URL directly to the product
const postRes = await api.post(`/products/${params.productId}/images`, {
url: uploadedUrl
});
return { content: [{ type: "text", text: JSON.stringify({ success: true, imageUrl: uploadedUrl }, null, 2) }] };
}
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 url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : "");
const res = await api.get(url);
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.id) {
payload.id = params.id;
}
const res = await api.post("/pages", payload);
const pageData = res.data?.page ?? res.data;
const normalized = {
success: res.data?.success ?? ((res.status >= 200) && (res.status < 300)),
operation: res.data?.operation ?? (params.id ? "update" : "create"),
pageId: res.data?.pageId ?? pageData?.id ?? null,
slug: res.data?.slug ?? pageData?.slug ?? params.slug,
title: res.data?.title ?? pageData?.title ?? params.title,
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false,
publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"),
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
page: pageData,
};
return { content: [{ type: "text", text: JSON.stringify(normalized, 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) }] };
}
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);