This commit is contained in:
Your Name committed 2026-07-24 04:04:32 -04:00
1 parent 0341b30b6d
commit bd8f5882e0
9 files changed
+1410 -25

No files matched your search

+146 -5
View File
@@ -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,
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import {
getPageBlockSchema,
handleSectionTool,
validatePayloadAgainstMetadata,
} from "./section-toolkit";
const websiteBlockTypes = [
"HERO",
"ANNOUNCEMENT_BAR",
"NEWSLETTER",
"EDITORIAL_TILE",
"EDITORIAL_CAROUSEL",
"PRODUCTS",
"CATEGORIES",
"TEXT",
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
"ACCORDION",
"CONTACT_INFO",
"CONTACT_FORM",
"HERO_V2",
"USP_BAR",
"CUSTOM",
] as const;
describe("Page Builder block contracts", () => {
for (const type of websiteBlockTypes) {
test(`${type} exposes a valid recommended payload`, () => {
const schema = getPageBlockSchema(type);
expect(schema.renderability.websitePageRenderable).toBe(true);
expect(schema.recommendedPayload).not.toBeNull();
expect(validatePayloadAgainstMetadata(type, schema.recommendedPayload).valid).toBe(true);
});
}
test("HERO_V2 rejects the legacy empty payload before backend writes", () => {
const result = validatePayloadAgainstMetadata("HERO_V2", {});
expect(result.valid).toBe(false);
expect(result.missingFields).toEqual(["title", "imageUrl"]);
});
test("USP_BAR rejects an empty payload because the website renders nothing", () => {
const result = validatePayloadAgainstMetadata("USP_BAR", {});
expect(result.valid).toBe(false);
expect(result.missingFields).toContain("items");
});
test("CUSTOM rejects component IDs missing from the website registry", () => {
const result = validatePayloadAgainstMetadata("CUSTOM", {
componentId: "UnknownComponent",
props: {},
});
expect(result.valid).toBe(false);
expect(result.validationIssues).toContainEqual({
path: "componentId",
message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.",
code: "custom",
});
});
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0;
const api = {
post: async () => {
postCalls += 1;
throw new Error("Backend must not be called");
},
} as any;
const result = await handleSectionTool(api, "create_page_with_section", {
title: "Invalid Hero",
slug: "invalid-hero",
type: "HERO_V2",
payload: {},
});
expect(result && "isError" in result ? result.isError : false).toBe(true);
expect(postCalls).toBe(0);
});
test("create_page_with_section uses one atomic backend call and returns compact output", async () => {
const calls: Array<{ url: string; payload: unknown }> = [];
const api = {
post: async (url: string, payload: unknown) => {
calls.push({ url, payload });
return {
data: {
success: true,
pageId: 50,
sectionId: 120,
page: { id: 50, slug: "hero-v2" },
section: { id: 120, type: "HERO_V2" },
},
};
},
} as any;
const payload = getPageBlockSchema("HERO_V2").recommendedPayload;
const result = await handleSectionTool(api, "create_page_with_section", {
title: "Hero V2",
slug: "hero-v2",
type: "HERO_V2",
payload,
isPublished: false,
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe("/pages/with-section");
expect(response).toEqual({
success: true,
tool: "create_page_with_section",
pageId: 50,
pageSlug: "hero-v2",
sectionId: 120,
type: "HERO_V2",
preview: {
lang: "th",
previewPath: "/th/hero-v2",
previewUrl: "/th/hero-v2",
},
warnings: [
"contentAlignment must be left, center, or right. Use an image URL allowed by apps/website.",
],
});
});
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -23,7 +23,7 @@ async function test() {
const waitResponse = async (ms: number) => {
await new Promise(resolve => setTimeout(resolve, ms));
const lines = responseData.trim().split("\n");
const lastLine = lines[lines.length - 1];
const lastLine = lines[lines.length - 1] ?? "";
try {
return JSON.parse(lastLine);
} catch (e) {