Files
homaroma/apps/mcp-server/index.ts
T
2026-07-25 20:05:46 -04:00

1755 lines
72 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, getLegacyFooterMenuDeprecation, handleMenuTool } from "./menu-toolkit";
import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, 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";
const CATEGORY_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
let authToken = "";
export const api = axios.create({
baseURL: BACKEND_URL,
});
function currentTargetEnvironment() {
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
return "custom";
}
function normalizeToolError(err: any) {
const data = err.response?.data;
if (!data) {
return err.message;
}
const validationIssues = data.validationIssues ?? data.issues;
if (Array.isArray(validationIssues) && validationIssues.length > 0) {
const missingOrInvalid = validationIssues
.map((issue: any) => `${issue.path ?? "(root)"}: ${issue.message ?? issue.code ?? "Invalid value"}`)
.join("; ");
return JSON.stringify({
error: data.error ?? data.message ?? "Validation failed",
summary: missingOrInvalid,
validationIssues,
raw: data,
});
}
return JSON.stringify(data);
}
// 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);
}
}
function validateCategoryLocaleMap(translations: any) {
if (!translations || typeof translations !== "object" || Array.isArray(translations)) {
throw new Error("translations must be an object keyed by locale codes.");
}
const entries = Object.entries(translations);
if (entries.length === 0) {
throw new Error("translations must include at least one locale.");
}
for (const [locale, value] of entries) {
if (!CATEGORY_LOCALES.includes(locale as any)) {
throw new Error(`Unsupported locale "${locale}". Supported locales: ${CATEGORY_LOCALES.join(", ")}`);
}
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`translations.${locale} must be a non-empty string.`);
}
}
}
function compactCategory(category: any) {
if (!category) return null;
return {
id: category.id,
name: category.name,
imageUrl: category.imageUrl ?? null,
productCount: category._count?.products ?? category.productCount ?? undefined,
translations: category.translations ?? undefined,
};
}
function compactProduct(product: any) {
if (!product) return null;
const images = Array.isArray(product.images) ? product.images : [];
const firstImage = images[0];
return {
id: product.id,
name: product.name,
slug: product.slug ?? undefined,
sku: product.sku ?? undefined,
price: product.price ?? product.variants?.[0]?.price ?? undefined,
imageUrl: firstImage?.url ?? firstImage?.imageUrl ?? product.imageUrl ?? null,
category: product.category ? compactCategory(product.category) : undefined,
brand: product.brand ? { id: product.brand.id, name: product.brand.name } : undefined,
};
}
const server = new Server(
{
name: "homaroma-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_target_environment",
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
inputSchema: { type: "object", properties: {} },
},
{
name: "set_target_environment",
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
inputSchema: {
type: "object",
properties: {
env: {
type: "string",
enum: ["local", "production"]
},
customUrl: {
type: "string",
description: "Optional custom URL to use for the API (e.g., https://api.pkh.kitizone.com/api)"
}
},
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 translations and product counts. Use lang=all when you need every localized name.",
inputSchema: {
type: "object",
properties: {
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }
}
}
},
{
name: "list_categories_metadata",
description: "List compact category IDs/names for Page Builder blocks. Use this before CATEGORIES.categoryIds or PRODUCTS.categoryId.",
inputSchema: {
type: "object",
properties: {
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }
}
}
},
{
name: "get_category_products",
description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" },
page: { type: "number", description: "Page number, default 1" },
limit: { type: "number", description: "Products per page, default 12" }
},
required: ["id"]
}
},
{
name: "create_category",
description: "Create a product category. Backend supports base name, imageUrl, and translations. It does not support slug, hierarchy, sort order, or active state for categories.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Base category name. Use Thai or the primary display name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL." },
translations: {
type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
},
dryRun: { type: "boolean", description: "Preview only. No category is created." }
},
required: ["name"]
}
},
{
name: "update_category",
description: "Update category base name, image URL, and/or translations. Use dryRun first. Backend supports name and imageUrl only for base category fields.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
name: { type: "string", description: "Optional base category name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." },
translations: {
type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
},
dryRun: { type: "boolean", description: "Preview only. No category is updated." }
},
required: ["id"]
}
},
{
name: "delete_category",
description: "Soft-delete a category by setting deletedAt. Backend refuses deletion when products still reference the category. Use dryRun first to check product count.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
dryRun: { type: "boolean", description: "Preview only. No category is deleted." }
},
required: ["id"]
}
},
{
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: "search_products_compact",
description: "Search products with compact output for Page Builder productIds. Prefer this over search_products before PRODUCTS.type=custom.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results. Default 10." },
lang: { type: "string", description: "Language code (e.g. th, en, all)" },
brandId: { type: "number", description: "Filter by brand ID" },
categoryId: { type: "number", description: "Filter by category 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", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language code" },
name: { type: "string", description: "Translated category name" }
},
required: ["id", "lang", "name"]
}
},
{
name: "update_category_translations",
description: "Add or update multiple category translations in one call. Supported locales: th, en, ja, zh, vi, ko, lo, it.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
translations: {
type: "object",
description: "Locale map, for example { \"th\": \"ชุดโซ่สเตอร์\", \"en\": \"Chain & Sprocket Kits\" }"
},
dryRun: { type: "boolean", description: "Preview only. No translations are updated." }
},
required: ["id", "translations"]
}
},
{
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: "get_footer_brand",
description: "Read the editable Footer Brand block (name, logo, localized description, and social links). Pass the returned version to update_footer_brand to prevent stale writes.",
inputSchema: { type: "object", properties: {} }
},
{
name: "update_footer_brand",
description: "Update Footer Brand fields without replacing footer menus. Supports localized description, dryRun preview, and expectedVersion conflict protection.",
inputSchema: {
type: "object",
properties: {
brandName: { type: "string" },
logoUrl: { type: ["string", "null"] },
description: { type: "object", description: "Locale-to-description map, e.g. { th: '...', en: '...' }" },
socialLinks: { type: "object", properties: { facebookUrl: { type: ["string", "null"] }, instagramUrl: { type: ["string", "null"] }, tiktokUrl: { type: ["string", "null"] } } },
dryRun: { type: "boolean" },
expectedVersion: { 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. 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" },
lang: { type: "string", description: "Preview/content language code. Default th." }
},
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." },
lang: { type: "string", description: "Preview language code. Default th." },
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: "Legacy tool for Footer brand description and social links only. Footer menu arrays are rejected; use the Footer-specific item tools instead.",
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: "Deprecated and rejected. Use add_footer_menu_item, update_footer_menu_item, delete_footer_menu_item, or reorder_footer_menu_items.",
items: {
type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
}
},
collectionLinks: {
type: "array",
description: "Deprecated and rejected. Website Collections come from Categories, not a footer menu.",
items: {
type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
}
},
supportLinks: {
type: "array",
description: "Deprecated and rejected. Use the Footer-specific item tools with menuName=footer-support.",
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 "get_target_environment": {
return {
content: [
{
type: "text",
text: JSON.stringify({
env: currentTargetEnvironment(),
baseUrl: BACKEND_URL,
localBaseUrl: LOCAL_BACKEND_URL,
productionBaseUrl: PRODUCTION_BACKEND_URL,
authenticated: Boolean(authToken),
}, null, 2)
}
]
};
}
case "set_target_environment": {
const env = params.env;
const customUrl = params.customUrl;
if (customUrl) {
BACKEND_URL = customUrl;
} else 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 lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : "";
const res = await api.get(`/categories${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_categories_metadata": {
const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : "";
const res = await api.get(`/categories${lang}`);
const categories = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
const compact = Array.isArray(categories) ? categories.map(compactCategory) : [];
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: compact.length,
categories: compact,
}, null, 2)
}]
};
}
case "get_category_products": {
const { id } = params as any;
const query = new URLSearchParams();
if (params.lang) query.set("lang", String(params.lang));
if (params.page) query.set("page", String(params.page));
if (params.limit) query.set("limit", String(params.limit));
const suffix = query.toString() ? `?${query.toString()}` : "";
const res = await api.get(`/categories/${id}/products${suffix}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_category": {
const { name, imageUrl = null, translations, dryRun } = params as any;
if (typeof name !== "string" || name.trim().length === 0) {
throw new Error("name must be a non-empty string.");
}
if (translations !== undefined) validateCategoryLocaleMap(translations);
const payload = { name: name.trim(), imageUrl };
if (dryRun) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
dryRun: true,
operation: "create_category",
preview: {
create: payload,
translations: translations ?? null,
backendSupport: {
fields: ["name", "imageUrl", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
},
},
}, null, 2)
}]
};
}
const created = await api.post(`/categories`, payload);
let translationResult: any = null;
if (translations) {
const mergedTranslations = { th: name.trim(), ...translations };
const translationRes = await api.patch(`/categories/${created.data.id}/translations`, { name: mergedTranslations });
translationResult = translationRes.data;
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
operation: "create_category",
category: translationResult?.category ?? created.data,
translations: translationResult?.translations ?? [],
}, null, 2)
}]
};
}
case "update_category": {
const { id, name, imageUrl, translations, dryRun } = params as any;
if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) {
throw new Error("name must be a non-empty string when provided.");
}
if (translations !== undefined) validateCategoryLocaleMap(translations);
const beforeList = await api.get(`/categories?lang=all`);
const before = Array.isArray(beforeList.data)
? beforeList.data.find((cat: any) => cat.id === id)
: null;
if (!before) {
throw new Error(`Category with id ${id} not found.`);
}
const baseUpdate: any = {};
if (name !== undefined) baseUpdate.name = name.trim();
if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl;
if (dryRun) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
dryRun: true,
operation: "update_category",
categoryId: id,
preview: {
before: compactCategory(before),
baseUpdate,
translations: translations ?? null,
backendSupport: {
fields: ["name", "imageUrl", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
},
},
}, null, 2)
}]
};
}
let updated: any = before;
if (Object.keys(baseUpdate).length > 0) {
const updateRes = await api.put(`/categories/${id}`, baseUpdate);
updated = updateRes.data;
}
let translationResult: any = null;
if (translations) {
const translationRes = await api.patch(`/categories/${id}/translations`, { name: translations });
translationResult = translationRes.data;
updated = translationResult?.category ?? updated;
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
operation: "update_category",
category: updated,
translations: translationResult?.translations ?? [],
}, null, 2)
}]
};
}
case "delete_category": {
const { id, dryRun } = params as any;
const details = await api.get(`/categories/${id}/products?lang=all&limit=1`);
const productCount = details.data?.category?._count?.products ?? details.data?.meta?.total ?? 0;
const category = details.data?.category ?? { id };
if (dryRun) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: productCount === 0,
dryRun: true,
operation: "delete_category",
category,
productCount,
blocked: productCount > 0,
message: productCount > 0
? "Backend will refuse soft deletion because this category has products."
: "Category can be soft-deleted. The record will be hidden from category lists but kept in the database.",
}, null, 2)
}]
};
}
const res = await api.delete(`/categories/${id}`);
return { content: [{ type: "text", text: JSON.stringify({ ...res.data, operation: "soft_delete_category", categoryId: id }, 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 "search_products_compact": {
const query = new URLSearchParams();
if (params.search) query.set("search", String(params.search));
query.set("limit", String(params.limit ?? 10));
if (params.lang) query.set("lang", String(params.lang));
if (params.brandId) query.set("brandId", String(params.brandId));
if (params.categoryId) query.set("categoryId", String(params.categoryId));
const res = await api.get(`/products?${query.toString()}`);
const products = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
const compact = Array.isArray(products) ? products.map(compactProduct) : [];
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: compact.length,
products: compact,
meta: res.data?.meta ?? undefined,
}, 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;
validateCategoryLocaleMap({ [lang]: name });
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_translations": {
const { id, translations, dryRun } = params as any;
validateCategoryLocaleMap(translations);
if (dryRun) {
const beforeList = await api.get(`/categories?lang=all`);
const before = Array.isArray(beforeList.data)
? beforeList.data.find((cat: any) => cat.id === id)
: null;
if (!before) {
throw new Error(`Category with id ${id} not found.`);
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
dryRun: true,
operation: "update_category_translations",
categoryId: id,
preview: {
before: compactCategory(before),
translations,
changedLocales: Object.keys(translations),
},
}, null, 2)
}]
};
}
const res = await api.patch(`/categories/${id}/translations`, {
name: translations
});
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 "get_footer_brand": {
const res = await api.get("/settings/footer-brand");
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_footer_brand": {
const payload: any = { ...params };
const social = payload.socialLinks;
delete payload.socialLinks;
if (social && typeof social === "object") Object.assign(payload, social);
const res = await api.patch("/settings/footer-brand", payload);
return { content: [{ type: "text", text: JSON.stringify({ success: true, tool: "update_footer_brand", ...res.data }, 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 query = new URLSearchParams();
if (params.includeInactive) query.set("includeInactive", "true");
if (params.lang) query.set("lang", String(params.lang));
const url = `/pages/${params.slug}` + (query.toString() ? `?${query.toString()}` : "");
const res = await api.get(url);
return {
content: [{
type: "text",
text: JSON.stringify({
...res.data,
preview: buildCmsPreview(params.slug, params.lang || "th"),
}, 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."),
preview: buildCmsPreview(params.slug, params.lang || "th"),
page: pageData,
};
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
}
case "update_footer_content": {
const deprecation = getLegacyFooterMenuDeprecation(params);
if (deprecation) {
return {
content: [{
type: "text",
text: JSON.stringify(deprecation, null, 2),
}],
isError: true,
};
}
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" });
}
}
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (err: any) {
const errorMsg = normalizeToolError(err);
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);