x
This commit is contained in:
1 parent
0341b30b6d
commit
bd8f5882e0
9 files changed
+1410
-25
No files matched your search
+146
-5
@@ -7,7 +7,7 @@ import {
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit";
|
||||
import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit";
|
||||
import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
|
||||
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
|
||||
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
|
||||
|
||||
@@ -22,6 +22,34 @@ export const api = axios.create({
|
||||
baseURL: BACKEND_URL,
|
||||
});
|
||||
|
||||
function currentTargetEnvironment() {
|
||||
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
|
||||
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function normalizeToolError(err: any) {
|
||||
const data = err.response?.data;
|
||||
if (!data) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
const validationIssues = data.validationIssues ?? data.issues;
|
||||
if (Array.isArray(validationIssues) && validationIssues.length > 0) {
|
||||
const missingOrInvalid = validationIssues
|
||||
.map((issue: any) => `${issue.path ?? "(root)"}: ${issue.message ?? issue.code ?? "Invalid value"}`)
|
||||
.join("; ");
|
||||
return JSON.stringify({
|
||||
error: data.error ?? data.message ?? "Validation failed",
|
||||
summary: missingOrInvalid,
|
||||
validationIssues,
|
||||
raw: data,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
// MCP server state is process-local because this server runs over stdio.
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
@@ -81,6 +109,22 @@ function compactCategory(category: any) {
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -96,6 +140,11 @@ const server = new Server(
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "get_target_environment",
|
||||
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "set_target_environment",
|
||||
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
|
||||
@@ -136,6 +185,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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.",
|
||||
@@ -210,6 +269,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -853,7 +926,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Slug of the page" },
|
||||
includeInactive: { type: "boolean", description: "Include inactive sections" }
|
||||
includeInactive: { type: "boolean", description: "Include inactive sections" },
|
||||
lang: { type: "string", description: "Preview/content language code. Default th." }
|
||||
},
|
||||
required: ["slug"]
|
||||
}
|
||||
@@ -867,6 +941,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
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"]
|
||||
@@ -934,6 +1009,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
switch (request.params.name) {
|
||||
case "get_target_environment": {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
env: currentTargetEnvironment(),
|
||||
baseUrl: BACKEND_URL,
|
||||
localBaseUrl: LOCAL_BACKEND_URL,
|
||||
productionBaseUrl: PRODUCTION_BACKEND_URL,
|
||||
authenticated: Boolean(authToken),
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "set_target_environment": {
|
||||
const env = params.env;
|
||||
const customUrl = params.customUrl;
|
||||
@@ -973,6 +1064,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
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();
|
||||
@@ -1132,6 +1239,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
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}`);
|
||||
@@ -1491,9 +1620,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
|
||||
}
|
||||
case "get_dynamic_page_detail": {
|
||||
const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : "");
|
||||
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, null, 2) }] };
|
||||
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 = {
|
||||
@@ -1515,6 +1655,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
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) }] };
|
||||
@@ -1567,7 +1708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
||||
const errorMsg = normalizeToolError(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
|
||||
isError: true,
|
||||
|
||||
Reference in new issue
Block a user