Files
homaroma/apps/mcp-server/index.ts
T
2026-06-13 23:10:33 -04:00

611 lines
24 KiB
TypeScript

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import FormData from "form-data";
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000/api";
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123";
let authToken = "";
export const api = axios.create({
baseURL: BACKEND_URL,
});
api.interceptors.request.use(async (config) => {
if (!authToken && config.url !== "/auth/login") {
console.error("[MCP] No token found. Attempting auto-login...");
await performLogin();
}
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
return config;
});
async function performLogin() {
try {
const res = await axios.post(`${BACKEND_URL}/auth/login`, {
username: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
});
if (res.data && res.data.token) {
authToken = res.data.token;
console.error("[MCP] Successfully logged in to Backend API.");
}
} catch (err: any) {
console.error("[MCP] Auto-login failed:", err.message);
}
}
const server = new Server(
{
name: "homaroma-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "ping_backend",
description: "Ping the backend to verify connection and authentication",
inputSchema: { type: "object", properties: {} },
},
{
name: "search_products",
description: "Search for products and get their basic info",
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)" }
}
}
},
{
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_product_content",
description: "Update product basic content (name, description, details)",
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.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Product ID" },
lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated product name" },
description: { type: "string", description: "Translated description" },
details: { type: "string", description: "Translated details (HTML or Markdown allowed)" }
},
required: ["id", "lang"]
}
},
{
name: "upload_product_image_from_url",
description: "Download image from URL, upload to backend, and append to product images",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
imageUrl: { type: "string", description: "URL of the image to download" }
},
required: ["productId", "imageUrl"]
}
},
{
name: "generate_mock_review",
description: "Create a dummy review for a product",
inputSchema: {
type: "object",
properties: {
productId: { type: "number" },
rating: { type: "number", description: "1-5" },
comment: { type: "string" },
customerName: { type: "string", description: "Name of the mock reviewer" }
},
required: ["productId", "rating", "customerName"]
}
},
{
name: "get_order_status",
description: "Get the status and details of a specific order",
inputSchema: {
type: "object",
properties: {
orderId: { type: "number" }
},
required: ["orderId"]
}
},
{
name: "list_pending_orders",
description: "List orders that are pending payment or processing",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "Max results" }
}
}
},
{
name: "update_order_tracking",
description: "Update the tracking number of an order and optionally its status",
inputSchema: {
type: "object",
properties: {
orderId: { type: "number" },
trackingNo: { type: "string" },
status: { type: "string", description: "Optional status to update (e.g. SHIPPED)" }
},
required: ["orderId", "trackingNo"]
}
},
{
name: "search_customer",
description: "Search for customers by name, email, phone, or line ID",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search term" }
}
}
},
{
name: "adjust_customer_points",
description: "Add or deduct points for a customer",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Customer ID" },
pointsDelta: { type: "number", description: "Amount of points to add (positive) or deduct (negative)" },
reason: { type: "string", description: "Reason for the adjustment" }
},
required: ["id", "pointsDelta"]
}
},
{
name: "generate_special_coupon",
description: "Generate a special discount coupon",
inputSchema: {
type: "object",
properties: {
code: { type: "string", description: "Coupon code (e.g. VIP20)" },
type: { type: "string", description: "PERCENTAGE, FIXED_AMOUNT, or FREE_SHIPPING" },
value: { type: "number", description: "Discount value" },
minPurchase: { type: "number", description: "Minimum purchase amount" },
maxDiscount: { type: "number", description: "Maximum discount amount for PERCENTAGE type" },
usageLimit: { type: "number", description: "Maximum number of times this coupon can be used" },
expiresAt: { type: "string", description: "ISO date string for expiration" }
},
required: ["code", "type", "value"]
}
},
{
name: "list_dynamic_pages",
description: "List all dynamic pages and layouts managed by the CMS",
inputSchema: { type: "object", properties: {} }
},
{
name: "create_dynamic_page",
description: "Create or update a dynamic page or layout",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Title of the page" },
slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" },
isPublished: { type: "boolean", description: "Publish status" },
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
metadata: { type: "object", description: "SEO metadata" },
id: { type: "number", description: "Numeric page ID (if updating existing page)" }
},
required: ["title", "slug"]
}
},
{
name: "add_page_section",
description: "Add a block section to a dynamic page layout",
inputSchema: {
type: "object",
properties: {
pageId: { type: "number", description: "The numeric ID of the page" },
type: { type: "string", description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
order: { type: "number", description: "Display order sequence (starts at 1)" },
payload: { type: "object", description: "The JSON payload matching the section type rules" },
isActive: { type: "boolean", description: "Initial active state" }
},
required: ["pageId", "type", "payload"]
}
},
{
name: "update_page_section",
description: "Update an existing block section payload, order or active state",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
type: { type: "string", description: "Optional new block type" },
order: { type: "number", description: "Optional new display order sequence" },
payload: { type: "object", description: "Optional new JSON payload matching the section type rules" },
isActive: { type: "boolean", description: "Optional new active state" }
},
required: ["sectionId"]
}
},
{
name: "delete_page_section",
description: "Delete a block section from a dynamic page layout",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
},
required: ["sectionId"]
}
},
{
name: "update_footer_content",
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
inputSchema: {
type: "object",
properties: {
brandDescription: { type: "string", description: "Text for Block 1 (Brand tagline)" },
socialLinks: {
type: "object",
description: "URLs for facebook, instagram, tiktok (e.g. { facebookUrl: '...' })",
properties: {
facebookUrl: { type: "string" },
instagramUrl: { type: "string" },
tiktokUrl: { type: "string" }
}
},
exploreLinks: {
type: "array",
description: "Array of { label, url, order } for Block 2 (Explore)",
items: {
type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
}
},
collectionLinks: {
type: "array",
description: "Array of { label, url, order } for Block 3 (Collections)",
items: {
type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
}
},
supportLinks: {
type: "array",
description: "Array of { label, url, order } for Block 4 (Support/Legal)",
items: {
type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
}
}
}
}
},
{
name: "audit_missing_pages",
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
inputSchema: { type: "object", properties: {} }
}
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const params = request.params.arguments as any;
try {
switch (request.params.name) {
case "ping_backend": {
const res = await api.get("/products?limit=1");
return { content: [{ type: "text", text: `Connection successful!` }] };
}
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 res = await api.get(`/products?${search}${limit}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_product_detail": {
const lang = params.lang ? `?lang=${params.lang}` : "";
const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "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;
const res = await api.patch(`/products/${params.id}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, 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 };
const res = await api.patch(`/products/${params.id}`, payload);
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. Get current product to append image
const prodRes = await api.get(`/products/${params.productId}`);
const product = prodRes.data;
const newImages = [...product.images, { url: uploadedUrl, order: product.images.length }];
// 4. Update product (using PUT to sync variants and images)
// Ensure we pass required fields
const putRes = await api.put(`/products/${params.productId}`, {
categoryId: product.categoryId,
description: product.description,
details: product.details,
variants: product.variants,
images: newImages
});
return { content: [{ type: "text", text: `Image uploaded and product updated. New image URL: ${uploadedUrl}` }] };
}
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 "adjust_customer_points": {
const payload = { pointsDelta: params.pointsDelta, reason: params.reason };
const res = await api.patch(`/customers/${params.id}/points`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "generate_special_coupon": {
const payload = {
code: params.code,
type: params.type,
value: params.value,
minPurchase: params.minPurchase,
maxDiscount: params.maxDiscount,
usageLimit: params.usageLimit,
expiresAt: params.expiresAt
};
const res = await api.post(`/coupons`, payload);
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 "create_dynamic_page": {
const payload: any = {
title: params.title,
slug: params.slug,
};
if (params.isPublished !== undefined) payload.isPublished = params.isPublished;
if (params.status !== undefined) payload.status = params.status;
if (params.metadata !== undefined) payload.metadata = params.metadata;
if (params.id) {
payload.id = params.id;
}
const res = await api.post("/pages", payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "add_page_section": {
const payload: any = {
type: params.type,
payload: params.payload,
isActive: params.isActive !== false
};
if (params.order !== undefined) payload.order = params.order;
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_page_section": {
const payload: any = {};
if (params.type) payload.type = params.type;
if (params.order !== undefined) payload.order = params.order;
if (params.payload) payload.payload = params.payload;
if (params.isActive !== undefined) payload.isActive = params.isActive;
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "delete_page_section": {
const res = await api.delete(`/pages/sections/${params.sectionId}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_footer_content": {
const results = [];
if (params.brandDescription !== undefined || params.socialLinks) {
const settingsPayload: any = {};
if (params.brandDescription !== undefined) settingsPayload.footerDescription = params.brandDescription;
if (params.socialLinks?.facebookUrl !== undefined) settingsPayload.facebookUrl = params.socialLinks.facebookUrl;
if (params.socialLinks?.instagramUrl !== undefined) settingsPayload.instagramUrl = params.socialLinks.instagramUrl;
if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl;
if (Object.keys(settingsPayload).length > 0) {
// First fetch existing settings because the backend requires full payload validation
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const mergedSettings = { ...currentSettings, ...settingsPayload };
// Remove null values so Elysia validation doesn't fail on t.Optional(t.String())
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
await api.put("/settings", mergedSettings);
results.push({ type: "settings", status: "success" });
}
}
if (params.exploreLinks) {
await api.put("/menus/footer-explore", { items: params.exploreLinks });
results.push({ type: "explore_links", status: "success" });
}
if (params.collectionLinks) {
await api.put("/menus/footer-collections", { items: params.collectionLinks });
results.push({ type: "collection_links", status: "success" });
}
if (params.supportLinks) {
await api.put("/menus/footer-support", { items: params.supportLinks });
results.push({ type: "support_links", status: "success" });
}
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
case "audit_missing_pages": {
// 1. Fetch all menus
const menusRes = await api.get("/menus");
const menus = menusRes.data || [];
// 2. Fetch all dynamic pages
const pagesRes = await api.get("/pages");
const pages = pagesRes.data || [];
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
// 3. Known SPA Hardcoded Routes
const hardcodedRoutes = [
"/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"
];
const missingLinks: any[] = [];
// 4. Audit
menus.forEach((menu: any) => {
if (menu.items && Array.isArray(menu.items)) {
menu.items.forEach((item: any) => {
const url = item.url;
if (!url) return;
// Skip external links
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
// Skip SPA Hardcoded routes
if (hardcodedRoutes.includes(url)) return;
// Skip dynamic collection routes natively handled by SPA (e.g. /collections/...)
if (url.startsWith("/collections/")) return;
// Check if it exists in created dynamic pages
if (!createdSlugs.includes(url)) {
missingLinks.push({
menuName: menu.name,
label: item.label,
url: url,
suggestedSlug: url.startsWith("/") ? url.substring(1) : url
});
}
});
}
});
return { content: [{ type: "text", text: JSON.stringify({ missingPages: missingLinks }, null, 2) }] };
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (err: any) {
const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message;
return {
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
isError: true,
};
}
});
async function main() {
await performLogin();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("HomAroma MCP Server running on stdio");
}
main().catch(console.error);