Files
2026-10-11 06:43:08 +07:00

1953 lines
84 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 type { CallToolRequest, CallToolResult, Tool } 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";
import { STOREFRONT_TOOL_DEFINITIONS, handleStorefrontTool } from "./storefront-toolkit";
import { ASSET_TOOL_DEFINITIONS, handleAssetTool } from "./asset-toolkit";
import { presentToolDefinitions, withStructuredContent } from "./tool-presentation";
import { loadTargetRegistry } from "./target-profiles";
const targetRegistry = loadTargetRegistry();
let activeProfileId = targetRegistry?.defaultProfileId;
const profileTokens = new Map<string, string>();
const TARGET_MANAGEMENT_TOOLS = new Set(["get_target_environment", "list_target_profiles", "select_target_profile", "set_target_environment"]);
function bindToolsToProfile(tools: Tool[]): Tool[] {
if (!targetRegistry) return tools;
return tools.filter((tool) => tool.name !== "upload_product_image_from_url").map((tool) => TARGET_MANAGEMENT_TOOLS.has(tool.name) ? tool : {
...tool,
description: `${tool.description ?? ""} In profile mode, pass expectedProfileId from get_target_environment; the call fails if another chat switched targets.`,
inputSchema: {
...tool.inputSchema,
properties: {
...tool.inputSchema.properties,
expectedProfileId: { type: "string", description: "Active target profile ID to guard against cross-customer calls." },
},
required: [...new Set([...(tool.inputSchema.required ?? []), "expectedProfileId"])],
},
});
}
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 = targetRegistry
? targetRegistry.profiles.find((profile) => profile.id === activeProfileId)!.baseUrl
: 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 (targetRegistry) return "profile";
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 (targetRegistry && (!config.url?.startsWith("/") || config.url.startsWith("//") || config.baseURL !== BACKEND_URL)) {
throw new Error("Profile requests must remain on the selected backend.");
}
const token = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
if (!token && config.url !== "/auth/login") {
console.error("[MCP] No token found. Attempting auto-login...");
await performLogin();
}
const currentToken = targetRegistry ? profileTokens.get(activeProfileId!) : authToken;
if (targetRegistry && !currentToken) throw new Error("Target profile authentication failed.");
if (currentToken) {
config.headers.Authorization = `Bearer ${currentToken}`;
}
if (targetRegistry) config.maxRedirects = 0;
return config;
});
async function performLogin() {
try {
const profile = targetRegistry?.profiles.find((item) => item.id === activeProfileId);
const res = await axios.post(`${BACKEND_URL}/auth/login`, {
username: profile ? process.env[profile.usernameEnv] : ADMIN_USERNAME,
password: profile ? process.env[profile.passwordEnv] : ADMIN_PASSWORD,
}, profile ? { maxRedirects: 0 } : undefined);
if (res.data && res.data.token) {
if (profile) profileTokens.set(profile.id, res.data.token);
else authToken = res.data.token;
console.error("[MCP] Successfully logged in to Backend API.");
}
} catch (err: any) {
console.error("[MCP] Auto-login failed:", targetRegistry ? "profile authentication 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,
media: category.media ?? [],
layoutType: category.layoutType ?? undefined,
productCount: category._count?.products ?? category.productCount ?? undefined,
translations: category.translations ?? undefined,
};
}
const categoryMediaSchema = {
type: "object",
properties: {
role: {
type: "string",
enum: ["thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share"],
description: "Semantic image usage; do not encode a layout name in this field."
},
url: { type: "string", description: "Image URL or website-relative media path." },
width: { type: ["number", "null"], description: "Optional intrinsic width in pixels." },
height: { type: ["number", "null"], description: "Optional intrinsic height in pixels." },
focalX: { type: "number", minimum: 0, maximum: 1, description: "Horizontal focal point from 0 to 1." },
focalY: { type: "number", minimum: 0, maximum: 1, description: "Vertical focal point from 0 to 1." },
sortOrder: { type: "number", minimum: 0 }
},
required: ["role", "url"]
};
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: {},
},
instructions: "Check get_target_environment and confirm the exact customer backend URL before writes. In profile mode, use list_target_profiles and select_target_profile to switch approved customer backends; legacy arbitrary URL switching is disabled. Prefer compact discovery tools, then read the target record before editing. Use dryRun and expectedVersion where offered; verify the result by reading it back. Production writes require an explicit user request.",
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: presentToolDefinitions(bindToolsToProfile([
{
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: "Legacy mode only: switch the backend target (local, production, or a trusted customUrl). Disabled when target profiles are configured; use select_target_profile instead.",
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.example.com/api)"
}
},
required: ["env"]
}
},
...(targetRegistry ? [
{
name: "list_target_profiles",
description: "List approved customer backend profiles (ID, display name, kind, URL; no credentials). Read before selecting a customer.",
inputSchema: { type: "object" as const, properties: {} },
},
{
name: "select_target_profile",
description: "Select an approved customer backend profile by ID. All later calls use only that profile's URL and credentials. Confirm the exact URL with get_target_environment before writes.",
inputSchema: { type: "object" as const, properties: { profileId: { type: "string" } }, required: ["profileId"] },
},
] : []),
...MENU_TOOL_DEFINITIONS,
...SECTION_TOOL_DEFINITIONS,
...STOREFRONT_TOOL_DEFINITIONS,
...ASSET_TOOL_DEFINITIONS,
{
name: "ping_backend",
description: "Check backend connectivity through the public products endpoint. This does not verify admin 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 with semantic responsive media and translations. imageUrl remains a legacy thumbnail fallback.",
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." },
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
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 fields, layout, semantic responsive media, and/or translations. Use dryRun first.",
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." },
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
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_media",
description: "Replace the complete semantic media set for a category. Omitted roles are removed. Use dryRun first.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
dryRun: { type: "boolean", description: "Preview only. No media is changed." }
},
required: ["id", "media"]
}
},
{
name: "update_category_image",
description: "Legacy compatibility tool. Update the category thumbnail only. Prefer update_category_media for responsive assets.",
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, newsletter, and default SEO 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" },
defaultSeoTitle: { type: "string" },
defaultSeoDescription: { 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 product. For a simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, put price and stock on each variant. 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" },
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
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. For each simple product without variants, pass root price and stock (and optional sku/barcode); omitted values default to 0. For products with variants, set price and stock on each variant.",
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" },
price: { type: "number", minimum: 0, description: "Simple product price; used only when variants are omitted." },
stock: { type: "number", minimum: 0, description: "Simple product stock; used only when variants are omitted." },
sku: { type: "string", description: "Simple product SKU; used only when variants are omitted." },
barcode: { type: "string", description: "Simple product barcode; used only when variants are omitted." },
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" } }
}
}
}
}
},
{
name: "get_brand_knowledge",
description: "Fetch structured Brand Knowledge, Core Directives, Visual Art Direction, and optionally the Master Compiled System Prompt for LLMs / AI Agents.",
inputSchema: {
type: "object",
properties: {
includeCompiledPrompt: {
type: "boolean",
description: "Whether to include the compiled markdown prompt in the response"
}
}
}
},
{
name: "update_brand_knowledge",
description: "Update Brand Knowledge and Directives (Identity, Tone of Voice, Visual Art Prompt, USPs, Marketing Guidelines, Custom Rules, Master Prompt Override).",
inputSchema: {
type: "object",
properties: {
brandName: { type: "string" },
tagline: { type: "string" },
story: { type: "string" },
targetAudience: { type: "string" },
toneOfVoice: { type: "string" },
visualArtPrompt: { type: "string" },
productUsps: { type: "string" },
marketingGuidelines: { type: "string" },
customRules: { type: "string" },
masterPromptOverride: { type: "string" }
}
}
},
] as Tool[])),
};
});
async function handleToolRequest(request: CallToolRequest) {
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;
}
const storefrontToolResponse = await handleStorefrontTool(api, request.params.name, params);
if (storefrontToolResponse) {
return storefrontToolResponse;
}
const assetToolResponse = await handleAssetTool(api, request.params.name, params);
if (assetToolResponse) {
return assetToolResponse;
}
switch (request.params.name) {
case "get_target_environment": {
const activeProfile = targetRegistry?.profiles.find((profile) => profile.id === activeProfileId);
return {
content: [
{
type: "text",
text: JSON.stringify({
mode: targetRegistry ? "profiles" : "legacy",
...(activeProfile ? { activeProfile: { id: activeProfile.id, displayName: activeProfile.displayName, kind: activeProfile.kind } } : {}),
env: currentTargetEnvironment(),
baseUrl: BACKEND_URL,
...(!targetRegistry ? { localBaseUrl: LOCAL_BACKEND_URL, productionBaseUrl: PRODUCTION_BACKEND_URL } : {}),
authenticated: targetRegistry ? Boolean(profileTokens.get(activeProfileId!)) : Boolean(authToken),
}, null, 2)
}
]
};
}
case "list_target_profiles": {
if (!targetRegistry) throw new Error("Target profiles are not configured.");
return { content: [{ type: "text", text: JSON.stringify({
activeProfileId,
profiles: targetRegistry.profiles.map(({ id, displayName, kind, baseUrl }) => ({ id, displayName, kind, baseUrl })),
}, null, 2) }] };
}
case "select_target_profile": {
if (!targetRegistry) throw new Error("Target profiles are not configured.");
const profile = targetRegistry.profiles.find((item) => item.id === params?.profileId);
if (!profile) throw new Error("Unknown target profile ID.");
activeProfileId = profile.id;
BACKEND_URL = profile.baseUrl;
api.defaults.baseURL = profile.baseUrl;
return { content: [{ type: "text", text: JSON.stringify({
mode: "profiles", activeProfile: { id: profile.id, displayName: profile.displayName, kind: profile.kind }, baseUrl: profile.baseUrl,
authenticated: Boolean(profileTokens.get(profile.id)),
}, null, 2) }] };
}
case "set_target_environment": {
if (targetRegistry) throw new Error("Legacy target switching is disabled in profile mode. Use select_target_profile.");
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, layoutType, media, 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, layoutType, media };
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", "layoutType", "media", "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, layoutType, media, 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 (layoutType !== undefined) baseUpdate.layoutType = layoutType;
if (media !== undefined) baseUpdate.media = media;
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", "layoutType", "media", "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_category_media": {
const { id, media, dryRun } = params as any;
if (!Array.isArray(media)) throw new Error("media must be an array.");
if (dryRun) {
const before = await api.get(`/categories/${id}?lang=all`);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
dryRun: true,
operation: "update_category_media",
categoryId: id,
before: compactCategory(before.data),
media,
note: "This is a full replacement; omitted roles will be removed."
}, null, 2)
}]
};
}
const res = await api.put(`/categories/${id}/media`, { media });
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, defaultSeoTitle, defaultSeoDescription } = params as any;
const payload: any = {};
if (footerDescription !== undefined) payload.footerDescription = { [lang]: footerDescription };
if (newsletterTitle !== undefined) payload.newsletterTitle = { [lang]: newsletterTitle };
if (newsletterDescription !== undefined) payload.newsletterDescription = { [lang]: newsletterDescription };
if (newsletterPlaceholder !== undefined) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
if (newsletterButtonText !== undefined) payload.newsletterButtonText = { [lang]: newsletterButtonText };
if (defaultSeoTitle !== undefined) payload.defaultSeoTitle = { [lang]: defaultSeoTitle };
if (defaultSeoDescription !== undefined) payload.defaultSeoDescription = { [lang]: defaultSeoDescription };
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 newTokens: Record<string, string> = {};
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];
}
}
if (Object.keys(newTokens).length === 0) {
throw new Error("At least one theme token must be supplied.");
}
const res = await api.put("/settings", { themeTokens: newTokens });
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 res = await api.put("/settings", { brandName });
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) {
await api.put("/settings", settingsPayload);
results.push({ type: "settings", status: "success" });
}
}
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
case "get_brand_knowledge": {
const res = await api.get("/brand-knowledge");
let result = res.data;
if (params.includeCompiledPrompt) {
const compiledRes = await api.get("/brand-knowledge/compiled-prompt");
result = {
...result,
compiledPrompt: compiledRes.data?.compiledPrompt ?? "",
};
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "update_brand_knowledge": {
const res = await api.put("/brand-knowledge", params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (err: any) {
const errorMsg = targetRegistry && axios.isAxiosError(err)
? `Backend request failed${err.response?.status ? ` (HTTP ${err.response.status})` : ""}.`
: normalizeToolError(err);
return {
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
isError: true,
};
}
}
// A profile switch cannot interleave with requests that still use the previous target.
let toolQueue: Promise<void> = Promise.resolve();
server.setRequestHandler(CallToolRequestSchema, (request) => {
const run = toolQueue.then(async () => {
if (targetRegistry && request.params.name === "upload_product_image_from_url") {
return { content: [{ type: "text" as const, text: "PROFILE_TOOL_UNAVAILABLE: upload_product_image_from_url is disabled in profile mode until the downloader has an outbound URL allowlist and resource limits." }], isError: true };
}
if (targetRegistry && !TARGET_MANAGEMENT_TOOLS.has(request.params.name)) {
const args = request.params.arguments;
if (!args || args.expectedProfileId !== activeProfileId) {
return { content: [{ type: "text" as const, text: "Target profile mismatch or missing expectedProfileId. Call get_target_environment and retry only after confirming the intended customer." }], isError: true };
}
const { expectedProfileId: _guard, ...backendArgs } = args;
const boundRequest = { ...request, params: { ...request.params, arguments: backendArgs } } as CallToolRequest;
return withStructuredContent(await handleToolRequest(boundRequest) as CallToolResult);
}
return withStructuredContent(await handleToolRequest(request) as CallToolResult);
});
toolQueue = run.then(() => undefined, () => undefined);
return run;
});
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);