This commit is contained in:
Your Name committed 2026-07-23 04:41:35 -04:00
1 parent ae3799d8ce
commit 0341b30b6d
9 files changed
+947 -51

No files matched your search

+308 -4
View File
@@ -14,6 +14,7 @@ const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https:
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 = "";
@@ -49,6 +50,37 @@ async function performLogin() {
}
}
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,
};
}
const server = new Server(
{
name: "homaroma-mcp",
@@ -96,8 +128,74 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "list_categories",
description: "List all product categories with their translations",
inputSchema: { type: "object", properties: {} }
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: "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",
@@ -131,12 +229,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
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.",
@@ -855,9 +969,161 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_categories": {
const res = await api.get(`/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 "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";
@@ -889,6 +1155,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
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 }
});
@@ -896,6 +1163,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
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`, {