feat(mcp): add BOM management tools (list_products_bom_status, get_product_bom, set_product_bom)
This commit is contained in:
1 parent
b030c08f68
commit
4fcdd8a816
4 files changed
+538
-1
No files matched your search
@@ -51,7 +51,7 @@ test("settings writes send only supported fields and simple product fields are a
|
||||
try {
|
||||
client = await connectMcp(`http://localhost:${backend.port}/api`);
|
||||
const tools = (await client.listTools()).tools;
|
||||
expect(tools).toHaveLength(113);
|
||||
expect(tools).toHaveLength(116);
|
||||
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
|
||||
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
|
||||
|
||||
@@ -371,6 +371,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "list_products_bom_status",
|
||||
description: "List products and their BOM (Bill of Materials) setup status (whether variants have formulas defined). Useful for finding products needing BOM configuration.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: { type: "string", description: "Search by product name, brand, or SKU" },
|
||||
status: { type: "string", enum: ["all", "missing", "configured", "partial"], description: "Filter by BOM status. Default is 'all'." },
|
||||
limit: { type: "number", description: "Maximum products to return. Default 20." }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_product_bom",
|
||||
description: "Get full BOM formula details, current material costs, gross profit, margin %, and available raw materials for a product. Use this to inspect a product's variants and determine required materials.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
productId: { type: "number", description: "Product ID" }
|
||||
},
|
||||
required: ["productId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "set_product_bom",
|
||||
description: "Set or update the BOM (Bill of Materials) formula for a product's variants. Matches variants by variantId or sku, and raw materials by rawMaterialId or name. Replaces the variant's BOM items and recalculates cost/margin.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
productId: { type: "number", description: "Product ID" },
|
||||
variants: {
|
||||
type: "array",
|
||||
description: "Array of variant BOM formulas to apply",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
variantId: { type: "number", description: "Variant ID (optional if sku provided)" },
|
||||
sku: { type: "string", description: "Variant SKU (optional if variantId provided)" },
|
||||
bomItems: {
|
||||
type: "array",
|
||||
description: "List of raw material ingredients/packaging for this variant",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
rawMaterialId: { type: "number", description: "Raw material ID (optional if name provided)" },
|
||||
name: { type: "string", description: "Raw material name (optional if rawMaterialId provided)" },
|
||||
quantity: { type: "number", description: "Quantity of raw material used per unit" }
|
||||
},
|
||||
required: ["quantity"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["bomItems"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["productId", "variants"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_category_translation",
|
||||
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
|
||||
@@ -1449,6 +1508,22 @@ async function handleToolRequest(request: CallToolRequest) {
|
||||
const res = await api.get(`/products/${params.id}${lang}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "list_products_bom_status": {
|
||||
const query = new URLSearchParams();
|
||||
if (params.search) query.set("search", String(params.search));
|
||||
if (params.status) query.set("status", String(params.status));
|
||||
query.set("limit", String(params.limit ?? 20));
|
||||
const res = await api.get(`/products/bom-status?${query.toString()}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "get_product_bom": {
|
||||
const res = await api.get(`/products/${params.productId}/bom`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "set_product_bom": {
|
||||
const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "create_product": {
|
||||
const res = await api.post(`/products`, params);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
async function main() {
|
||||
console.log("Connecting to MCP server...");
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(import.meta.dir, "index.ts")],
|
||||
cwd: import.meta.dir,
|
||||
env: {
|
||||
...process.env,
|
||||
MCP_BACKEND_URL: "http://localhost:3051/api",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const client = new Client({ name: "bom-test-client", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
console.log("Connected to MCP server!");
|
||||
|
||||
// 1. List tools and verify BOM tools are registered
|
||||
const tools = (await client.listTools()).tools;
|
||||
console.log(`Total MCP tools registered: ${tools.length}`);
|
||||
|
||||
const bomTools = ["list_products_bom_status", "get_product_bom", "set_product_bom"];
|
||||
for (const name of bomTools) {
|
||||
const found = tools.find((t) => t.name === name);
|
||||
if (!found) {
|
||||
throw new Error(`Tool ${name} not found!`);
|
||||
}
|
||||
console.log(`✓ Found tool: ${name}`);
|
||||
}
|
||||
|
||||
// 2. Call list_products_bom_status
|
||||
console.log("\nCalling list_products_bom_status for search='ซากุระ'...");
|
||||
const statusRes = await client.callTool({
|
||||
name: "list_products_bom_status",
|
||||
arguments: { search: "ซากุระ", limit: 5 },
|
||||
});
|
||||
console.log("Status result:", (statusRes.content as any)[0].text.slice(0, 300) + "...");
|
||||
|
||||
// 3. Call get_product_bom for Product #250
|
||||
console.log("\nCalling get_product_bom for productId=250...");
|
||||
const getBomRes = await client.callTool({
|
||||
name: "get_product_bom",
|
||||
arguments: { productId: 250 },
|
||||
});
|
||||
const bomData = JSON.parse((getBomRes.content as any)[0].text);
|
||||
console.log(`Product Name: ${bomData.productName}`);
|
||||
console.log(`Variants count: ${bomData.variants.length}`);
|
||||
console.log(`Available Raw Materials count: ${bomData.availableRawMaterials.length}`);
|
||||
|
||||
// 4. Call set_product_bom for Product #250
|
||||
console.log("\nCalling set_product_bom for Product #250 with 4 variants...");
|
||||
const setBomRes = await client.callTool({
|
||||
name: "set_product_bom",
|
||||
arguments: {
|
||||
productId: 250,
|
||||
variants: [
|
||||
{
|
||||
variantId: 3206,
|
||||
sku: "4-1-30ML",
|
||||
bomItems: [
|
||||
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 2.4 },
|
||||
{ rawMaterialId: 91, name: "DPG", quantity: 27.6 },
|
||||
{ rawMaterialId: 92, name: "ขวดพร้อมฝา Oilozone 30ml", quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
variantId: 3207,
|
||||
sku: "4-1-60ML",
|
||||
bomItems: [
|
||||
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 4.8 },
|
||||
{ rawMaterialId: 91, name: "DPG", quantity: 55.2 },
|
||||
{ rawMaterialId: 93, name: "ขวดพร้อมฝา Oilozone 60ml", quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
variantId: 3208,
|
||||
sku: "4-1-85ML",
|
||||
bomItems: [
|
||||
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 6.8 },
|
||||
{ rawMaterialId: 91, name: "DPG", quantity: 78.2 },
|
||||
{ rawMaterialId: 94, name: "ขวดพร้อมฝา Oilozone 85ml", quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
variantId: 3209,
|
||||
sku: "4-1-120ML",
|
||||
bomItems: [
|
||||
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 9.6 },
|
||||
{ rawMaterialId: 91, name: "DPG", quantity: 110.4 },
|
||||
{ rawMaterialId: 95, name: "ขวดพร้อมฝา Oilozone 120ml", quantity: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const setResult = JSON.parse((setBomRes.content as any)[0].text);
|
||||
console.log("\nSet BOM result:");
|
||||
console.log(`Success: ${setResult.success}`);
|
||||
console.log(`Message: ${setResult.message}`);
|
||||
for (const v of setResult.variants) {
|
||||
console.log(` - Variant: ${v.option} (SKU: ${v.sku}) | Retail: ฿${v.retailPrice} | BOM Cost: ฿${v.totalBomCost} | Margin: ${v.marginPercent}%`);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
console.log("\nAll BOM MCP tests passed successfully!");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Test failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user