Files
homaroma/apps/mcp-server/index.ts
T
2026-06-24 19:15:04 -04:00

910 lines
36 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,
});
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_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 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_page_translations",
description: "Add or update multiple page title translations in one call. Use this when you want to set several languages at once instead of calling update_page_translation repeatedly.",
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" }
}
}
},
{
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. Minimum required fields are categoryId and brandId.",
inputSchema: {
type: "object",
properties: {
categoryId: { type: "number" },
brandId: { type: "number" },
name: { type: "string" },
description: { type: "string" },
details: { type: "string" },
price: { type: "number", description: "Base price if no variants are provided" },
options: {
type: "array",
description: "Array of option definitions (max 3). E.g., [{'name': 'Size', 'values': ['S', 'M']}]",
items: {
type: "object",
properties: {
name: { type: "string" },
values: { type: "array", items: { type: "string" } }
},
required: ["name", "values"]
}
},
variants: {
type: "array",
description: "List of variants. 'option1' maps to options[0].values, 'option2' to options[1].values, etc.",
items: {
type: "object",
properties: {
sku: { type: "string" },
barcode: { type: "string" },
price: { type: "number" },
stock: { type: "number", default: 0 },
option1: { type: "string" },
option2: { type: "string" },
option3: { type: "string" }
},
required: ["option1"]
}
}
},
required: ["categoryId", "brandId"]
}
},
{
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: "string" },
values: { type: "array", items: { type: "string" } }
},
required: ["name", "values"]
}
},
variants: {
type: "array",
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" },
option2: { type: "string" },
option3: { type: "string" }
},
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)" }
},
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" },
includeInactive: { type: "boolean", description: "Include inactive sections" }
},
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. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
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. 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 "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_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"
];
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 };
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 "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.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);
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 ?? true,
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);