x
This commit is contained in:
1 parent
ae3799d8ce
commit
0341b30b6d
9 files changed
+947
-51
No files matched your search
+308
-4
@@ -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`, {
|
||||
|
||||
Reference in new issue
Block a user