feat: add GoodGo MCP server for AI-assisted F&B operations

MCP server with 12 tools across 4 groups:
- Catalog: list/create/update/delete products
- Inventory: check stock, record intake/usage, low stock alerts
- Recipes: list and create recipes with ingredients
- Analytics: popular items, cost analysis

Uses @modelcontextprotocol/sdk with stdio transport for Claude Code integration.
Connects to catalog-service, inventory-service, fnb-engine via REST APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-15 12:55:58 +07:00
parent 6263eeb05d
commit b7a194f14b
9 changed files with 990 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# GoodGo MCP Server Configuration
CATALOG_API_URL=http://localhost:5002/api/v1
INVENTORY_API_URL=http://localhost:5003/api/v1
FNB_API_URL=http://localhost:5019/api/v1
ORDER_API_URL=http://localhost:5004/api/v1
# Default shop for convenience (Cobic Coffee)
DEFAULT_SHOP_ID=e1f392af-fe95-4c7f-8656-5b74ad5fd0a9
# JWT token (get from IAM login)
API_TOKEN=

View File

@@ -0,0 +1,23 @@
{
"name": "@goodgo/mcp-server",
"version": "1.0.0",
"description": "GoodGo MCP Server — AI-assisted F&B operations for catalog, inventory, recipes, and analytics",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.24.0",
"axios": "^1.7.0",
"dotenv": "^16.4.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0"
}
}

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* GoodGo MCP Server — AI-assisted F&B operations
*
* Exposes tools for catalog, inventory, recipe, and analytics management
* through the Model Context Protocol (MCP).
*
* Transport: stdio (for Claude Code local integration)
*
* Usage:
* claude mcp add --transport stdio goodgo -- node dist/index.js
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import dotenv from "dotenv";
import { registerCatalogTools } from "./tools/catalog-tools.js";
import { registerInventoryTools } from "./tools/inventory-tools.js";
import { registerRecipeTools } from "./tools/recipe-tools.js";
import { registerAnalyticsTools } from "./tools/analytics-tools.js";
dotenv.config();
const server = new McpServer({
name: "goodgo",
version: "1.0.0",
description: `GoodGo MCP Server — AI assistant for F&B shop operations.
Available tool groups:
- Catalog: list/create/update/delete products and menu items
- Inventory: check stock, record intake (nhập kho), record usage (xuất kho), low stock alerts
- Recipes: list and create recipes with ingredients
- Analytics: popular items, cost analysis
Default shop: Cobic Coffee (${process.env.DEFAULT_SHOP_ID || "e1f392af-fe95-4c7f-8656-5b74ad5fd0a9"})
If user doesn't specify shopId, use the default.
All prices are in VND (Vietnamese Dong). Format as X.000đ (e.g., 45.000đ).
`,
});
// Register all tool groups
registerCatalogTools(server);
registerInventoryTools(server);
registerRecipeTools(server);
registerAnalyticsTools(server);
// Start server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GoodGo MCP Server started (stdio transport)");

View File

@@ -0,0 +1,41 @@
import axios, { type AxiosInstance } from "axios";
import dotenv from "dotenv";
dotenv.config();
function createClient(baseURL: string): AxiosInstance {
const client = axios.create({
baseURL,
timeout: 15000,
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const token = process.env.API_TOKEN;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return client;
}
export const catalogApi = createClient(
process.env.CATALOG_API_URL || "http://localhost:5002/api/v1"
);
export const inventoryApi = createClient(
process.env.INVENTORY_API_URL || "http://localhost:5003/api/v1"
);
export const fnbApi = createClient(
process.env.FNB_API_URL || "http://localhost:5019/api/v1"
);
export const orderApi = createClient(
process.env.ORDER_API_URL || "http://localhost:5004/api/v1"
);
export const DEFAULT_SHOP_ID =
process.env.DEFAULT_SHOP_ID || "e1f392af-fe95-4c7f-8656-5b74ad5fd0a9";

View File

@@ -0,0 +1,190 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { orderApi, catalogApi, inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
interface TopItem {
productName: string;
quantitySold: number;
revenue: number;
}
interface DashboardData {
totalRevenue: number;
orderCount: number;
itemsSold: number;
avgOrderValue: number;
topItems: TopItem[];
revenueByHour: any[];
}
interface InventoryItem {
id: string;
name: string;
quantity: number;
costPerUnit: number;
sku?: string;
}
export function registerAnalyticsTools(server: McpServer): void {
server.tool(
"popular_items",
"Get top selling products by analyzing order data. Shows product name, quantity sold, and revenue.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
period: z
.enum(["today", "week", "month"])
.default("week")
.describe("Time period to analyze (default: week)"),
},
async ({ shopId, period }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const response = await orderApi.get(`/orders/dashboard`, {
params: { shopId: resolvedShopId, period },
});
const dashboard: DashboardData =
response.data?.data ?? response.data ?? {};
const topItems: TopItem[] = dashboard.topItems ?? [];
if (topItems.length === 0) {
return {
content: [
{
type: "text" as const,
text: `No sales data found for period: ${period}.`,
},
],
};
}
const lines = topItems.map((item, i) => {
const rank = `#${i + 1}`;
const revenue = item.revenue.toLocaleString();
return `${rank} ${item.productName}${item.quantitySold} sold (${revenue} VND revenue)`;
});
const summary = [
`Top Selling Products (${period})`,
`${"—".repeat(40)}`,
`Total Revenue: ${dashboard.totalRevenue.toLocaleString()} VND`,
`Total Orders: ${dashboard.orderCount}`,
`Items Sold: ${dashboard.itemsSold}`,
`Avg Order Value: ${dashboard.avgOrderValue.toLocaleString()} VND`,
``,
`Rankings:`,
...lines,
].join("\n");
return {
content: [{ type: "text" as const, text: summary }],
};
} catch (error: any) {
const message = error.response?.data?.error?.message || error.message;
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true,
};
}
}
);
server.tool(
"cost_analysis",
"Analyze cost structure by comparing inventory costs with revenue. Shows gross margin and cost breakdown.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
},
async ({ shopId }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const [inventoryResponse, dashboardResponse] = await Promise.all([
inventoryApi.get(`/inventory`, {
params: { shopId: resolvedShopId, take: 100 },
}),
orderApi.get(`/orders/dashboard`, {
params: { shopId: resolvedShopId, period: "month" },
}),
]);
const inventoryItems: InventoryItem[] =
inventoryResponse.data?.data?.items ??
inventoryResponse.data?.data ??
inventoryResponse.data?.items ??
inventoryResponse.data ??
[];
const dashboard: DashboardData =
dashboardResponse.data?.data ?? dashboardResponse.data ?? {};
const totalInventoryCost = Array.isArray(inventoryItems)
? inventoryItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 0),
0
)
: 0;
const totalRevenue = dashboard.totalRevenue ?? 0;
const grossMargin =
totalRevenue > 0
? ((totalRevenue - totalInventoryCost) / totalRevenue) * 100
: 0;
const costBreakdown = Array.isArray(inventoryItems)
? inventoryItems
.filter((item) => item.costPerUnit > 0)
.sort(
(a, b) =>
b.quantity * b.costPerUnit - a.quantity * a.costPerUnit
)
.slice(0, 15)
.map((item) => {
const itemCost = item.quantity * item.costPerUnit;
return ` - ${item.name}: ${item.quantity} units @ ${item.costPerUnit.toLocaleString()} VND = ${itemCost.toLocaleString()} VND`;
})
: [];
const report = [
`Cost Analysis Report (Monthly)`,
`${"=".repeat(45)}`,
``,
`Revenue: ${totalRevenue.toLocaleString()} VND`,
`Inventory Cost: ${totalInventoryCost.toLocaleString()} VND`,
`Gross Profit: ${(totalRevenue - totalInventoryCost).toLocaleString()} VND`,
`Gross Margin: ${grossMargin.toFixed(1)}%`,
``,
`Orders This Month: ${dashboard.orderCount ?? 0}`,
`Items Sold: ${dashboard.itemsSold ?? 0}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString()} VND`,
``,
`Top Cost Items (by total cost):`,
...(costBreakdown.length > 0
? costBreakdown
: [" (no cost data available)"]),
``,
`Inventory Items Tracked: ${Array.isArray(inventoryItems) ? inventoryItems.length : 0}`,
].join("\n");
return {
content: [{ type: "text" as const, text: report }],
};
} catch (error: any) {
const message = error.response?.data?.error?.message || error.message;
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true,
};
}
}
);
}

View File

@@ -0,0 +1,246 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { catalogApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatPrice(price: number): string {
return new Intl.NumberFormat("vi-VN", {
style: "currency",
currency: "VND",
maximumFractionDigits: 0,
}).format(price);
}
function errorResponse(err: unknown) {
const message =
err instanceof Error ? err.message : "Unknown error occurred";
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
}
function textResponse(text: string) {
return { content: [{ type: "text" as const, text }] };
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
export function registerCatalogTools(server: McpServer): void {
// -----------------------------------------------------------------------
// 1. list_products
// -----------------------------------------------------------------------
server.tool(
"list_products",
"List menu items/products for a shop. Returns name, price, category, stock status.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
categoryId: z
.string()
.uuid()
.optional()
.describe("Filter by category ID"),
isActive: z
.boolean()
.optional()
.default(true)
.describe("Filter by active status"),
page: z.number().int().positive().optional().default(1),
pageSize: z
.number()
.int()
.positive()
.max(200)
.optional()
.default(50),
},
async ({ shopId, categoryId, isActive, page, pageSize }) => {
try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const params: Record<string, unknown> = {
isActive,
page,
pageSize,
};
if (categoryId) params.categoryId = categoryId;
const { data: res } = await catalogApi.get(
`/shops/${resolvedShopId}/products`,
{ params },
);
const items: any[] =
res?.data?.items ?? res?.data ?? res?.items ?? res ?? [];
if (!Array.isArray(items) || items.length === 0) {
return textResponse(
"No products found for this shop. (Khong tim thay san pham nao.)",
);
}
// Build a text table
const header = `${"Name".padEnd(30)} | ${"Price".padStart(14)} | ${"Category".padEnd(20)} | Active`;
const separator = "-".repeat(header.length);
const rows = items.map((p: any) => {
const name = (p.name ?? "—").toString().slice(0, 29).padEnd(30);
const price = formatPrice(p.price ?? 0).padStart(14);
const category = (
p.categoryName ??
p.category ??
"—"
)
.toString()
.slice(0, 19)
.padEnd(20);
const active = p.isActive !== false ? "Yes" : "No";
return `${name} | ${price} | ${category} | ${active}`;
});
const totalCount =
res?.data?.totalCount ?? res?.totalCount ?? items.length;
const output = [
`Products for shop ${resolvedShopId} (page ${page}, showing ${items.length} of ${totalCount}):`,
"",
header,
separator,
...rows,
].join("\n");
return textResponse(output);
} catch (err) {
return errorResponse(err);
}
},
);
// -----------------------------------------------------------------------
// 2. create_product
// -----------------------------------------------------------------------
server.tool(
"create_product",
"Create a new product/menu item in the shop catalog.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
name: z.string().min(1).describe("Product name"),
description: z.string().optional().describe("Product description"),
price: z.number().positive().describe("Product price in VND"),
type: z
.string()
.optional()
.default("PreparedFood")
.describe("Product type (e.g. PreparedFood, Beverage, RetailItem)"),
categoryId: z.string().uuid().optional().describe("Category ID"),
sku: z.string().optional().describe("Stock-keeping unit code"),
imageUrl: z.string().optional().describe("Image URL"),
},
async ({ shopId, name, description, price, type, categoryId, sku, imageUrl }) => {
try {
const body: Record<string, unknown> = {
shopId: shopId ?? DEFAULT_SHOP_ID,
name,
price,
type,
};
if (description) body.description = description;
if (categoryId) body.categoryId = categoryId;
if (sku) body.sku = sku;
if (imageUrl) body.imageUrl = imageUrl;
const { data: res } = await catalogApi.post("/products", body);
const productId = res?.data?.id ?? res?.id ?? "unknown";
return textResponse(
`Product created successfully.\n` +
` ID : ${productId}\n` +
` Name : ${name}\n` +
` Price: ${formatPrice(price)}`,
);
} catch (err) {
return errorResponse(err);
}
},
);
// -----------------------------------------------------------------------
// 3. update_product
// -----------------------------------------------------------------------
server.tool(
"update_product",
"Update an existing product's name, price, description, or category.",
{
productId: z.string().uuid().describe("Product ID to update"),
name: z.string().optional().describe("New product name"),
description: z.string().optional().describe("New description"),
price: z
.number()
.positive()
.optional()
.describe("New price in VND"),
categoryId: z.string().uuid().optional().describe("New category ID"),
imageUrl: z.string().optional().describe("New image URL"),
},
async ({ productId, name, description, price, categoryId, imageUrl }) => {
try {
const body: Record<string, unknown> = {};
if (name !== undefined) body.name = name;
if (description !== undefined) body.description = description;
if (price !== undefined) body.price = price;
if (categoryId !== undefined) body.categoryId = categoryId;
if (imageUrl !== undefined) body.imageUrl = imageUrl;
if (Object.keys(body).length === 0) {
return textResponse(
"No fields provided to update. Please specify at least one field.",
);
}
await catalogApi.put(`/products/${productId}`, body);
const changes = Object.entries(body)
.map(([k, v]) => ` ${k}: ${k === "price" ? formatPrice(v as number) : v}`)
.join("\n");
return textResponse(
`Product ${productId} updated successfully.\nUpdated fields:\n${changes}`,
);
} catch (err) {
return errorResponse(err);
}
},
);
// -----------------------------------------------------------------------
// 4. delete_product
// -----------------------------------------------------------------------
server.tool(
"delete_product",
"Delete a product from the catalog.",
{
productId: z.string().uuid().describe("Product ID to delete"),
},
async ({ productId }) => {
try {
await catalogApi.delete(`/products/${productId}`);
return textResponse(
`Product ${productId} deleted successfully. (San pham da bi xoa.)`,
);
} catch (err) {
return errorResponse(err);
}
},
);
}

View File

@@ -0,0 +1,224 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
export function registerInventoryTools(server: McpServer): void {
// ─── 1. check_inventory ───────────────────────────────────────────────
server.tool(
"check_inventory",
"Check current inventory/stock levels for a shop. Shows item name, quantity, unit, reorder level, and low stock warnings.",
{
shopId: z
.string()
.uuid()
.optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"),
skip: z.number().int().min(0).optional().default(0).describe("Records to skip"),
take: z.number().int().min(1).max(200).optional().default(50).describe("Records to take"),
},
async ({ shopId, skip, take }) => {
try {
const response = await inventoryApi.get("/inventory", {
params: { shopId, skip, take },
});
const payload = response.data.data;
const items: any[] = payload.items ?? [];
const totalCount: number = payload.totalCount ?? items.length;
if (items.length === 0) {
return {
content: [
{
type: "text" as const,
text: `No inventory items found for shop ${shopId}.`,
},
],
};
}
const header = `Inventory (${items.length} of ${totalCount} items)\n${"─".repeat(70)}`;
const rows = items.map((item: any) => {
const qty = item.quantity ?? item.currentStock ?? 0;
const reorder = item.reorderLevel ?? item.reorderPoint ?? 0;
const status = qty <= reorder ? "\u26a0\ufe0f Low" : "\u2705 OK";
const name = item.itemName ?? item.productName ?? item.name ?? "Unknown";
const unit = item.unit ?? item.unitOfMeasure ?? "-";
return `${name} | ${qty} | ${unit} | ${reorder} | ${status}`;
});
const table = [
header,
"Name | Qty | Unit | Reorder Level | Status",
"─".repeat(70),
...rows,
"─".repeat(70),
`Page: skip=${skip}, take=${take} | Total: ${totalCount}`,
].join("\n");
return { content: [{ type: "text" as const, text: table }] };
} catch (error: any) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error);
return {
content: [{ type: "text" as const, text: `Error checking inventory: ${msg}` }],
isError: true,
};
}
}
);
// ─── 2. record_intake (nhap kho) ─────────────────────────────────────
server.tool(
"record_intake",
"Record stock intake (nhap kho) — when goods are received from suppliers.",
{
shopId: z
.string()
.uuid()
.optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"),
productId: z.string().uuid().describe("Product ID to stock in"),
amount: z.number().positive().describe("Quantity to add"),
notes: z.string().optional().describe("Optional notes for this intake"),
unitCost: z.number().positive().optional().describe("Cost per unit (optional)"),
},
async ({ shopId, productId, amount, notes, unitCost }) => {
try {
const response = await inventoryApi.post("/inventory/stock-in", {
productId,
shopId,
amount,
notes,
unitCost,
});
const transactionId = response.data.data;
const text = [
`Stock intake recorded successfully.`,
` Product: ${productId}`,
` Amount: +${amount}`,
unitCost != null ? ` Unit Cost: ${unitCost}` : null,
notes ? ` Notes: ${notes}` : null,
` Transaction ID: ${transactionId}`,
]
.filter(Boolean)
.join("\n");
return { content: [{ type: "text" as const, text }] };
} catch (error: any) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error);
return {
content: [{ type: "text" as const, text: `Error recording intake: ${msg}` }],
isError: true,
};
}
}
);
// ─── 3. record_usage (xuat kho) ──────────────────────────────────────
server.tool(
"record_usage",
"Record stock usage/outflow (xuat kho) — when items are consumed, wasted, or adjusted.",
{
shopId: z
.string()
.uuid()
.optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"),
productId: z.string().uuid().describe("Product ID to stock out"),
amount: z.number().positive().describe("Quantity to remove"),
notes: z.string().optional().describe("Optional notes (reason for usage)"),
},
async ({ shopId, productId, amount, notes }) => {
try {
await inventoryApi.post("/inventory/stock-out", {
productId,
shopId,
amount,
notes,
});
const text = [
`Stock usage recorded successfully.`,
` Product: ${productId}`,
` Amount: -${amount}`,
notes ? ` Notes: ${notes}` : null,
]
.filter(Boolean)
.join("\n");
return { content: [{ type: "text" as const, text }] };
} catch (error: any) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error);
return {
content: [{ type: "text" as const, text: `Error recording usage: ${msg}` }],
isError: true,
};
}
}
);
// ─── 4. low_stock_alerts ──────────────────────────────────────────────
server.tool(
"low_stock_alerts",
"Get items that are at or below their reorder level. Critical for preventing stockouts.",
{
shopId: z
.string()
.uuid()
.optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"),
skip: z.number().int().min(0).optional().default(0).describe("Records to skip"),
take: z.number().int().min(1).max(200).optional().default(50).describe("Records to take"),
},
async ({ shopId, skip, take }) => {
try {
const response = await inventoryApi.get("/inventory/low-stock", {
params: { shopId, skip, take },
});
const payload = response.data.data;
const items: any[] = payload.items ?? [];
const totalCount: number = payload.totalCount ?? items.length;
if (items.length === 0) {
return {
content: [
{
type: "text" as const,
text: `No low-stock items found for shop ${shopId}. All stock levels are healthy.`,
},
],
};
}
const header = `\u26a0\ufe0f Low Stock Alerts (${items.length} of ${totalCount} items)\n${"─".repeat(60)}`;
const rows = items.map((item: any) => {
const name = item.itemName ?? item.productName ?? item.name ?? "Unknown";
const qty = item.quantity ?? item.currentStock ?? 0;
const reorder = item.reorderLevel ?? item.reorderPoint ?? 0;
const unit = item.unit ?? item.unitOfMeasure ?? "-";
const deficit = reorder - qty;
return `\u26a0\ufe0f ${name}: ${qty} ${unit} (reorder at ${reorder}, need ${deficit > 0 ? deficit : 0} more)`;
});
const text = [header, ...rows, "─".repeat(60), `Total low-stock items: ${totalCount}`].join(
"\n"
);
return { content: [{ type: "text" as const, text }] };
} catch (error: any) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error);
return {
content: [{ type: "text" as const, text: `Error fetching low stock alerts: ${msg}` }],
isError: true,
};
}
}
);
}

View File

@@ -0,0 +1,184 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { fnbApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
interface RecipeIngredient {
ingredientName: string;
quantity: number;
unit: string;
costPerUnit?: number;
inventoryItemId?: string;
}
interface Recipe {
id: string;
productId: string;
shopId: string;
name: string;
instructions: string;
prepTimeMinutes: number;
isActive: boolean;
ingredients: RecipeIngredient[];
}
export function registerRecipeTools(server: McpServer): void {
server.tool(
"list_recipes",
"List all recipes for a shop. Shows recipe name, linked product, prep time, and ingredients.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
},
async ({ shopId }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const response = await fnbApi.get(`/kitchen/recipes`, {
params: { shopId: resolvedShopId },
});
const recipes: Recipe[] = response.data?.data ?? response.data ?? [];
if (recipes.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No recipes found for this shop.",
},
],
};
}
const lines = recipes.map((r, i) => {
const status = r.isActive ? "Active" : "Inactive";
const ingredientLines =
r.ingredients && r.ingredients.length > 0
? r.ingredients
.map((ing) => {
const cost =
ing.costPerUnit != null
? ` @ ${ing.costPerUnit.toLocaleString()}đ/unit`
: "";
return ` - ${ing.ingredientName}: ${ing.quantity} ${ing.unit}${cost}`;
})
.join("\n")
: " (no ingredients)";
return [
`${i + 1}. ${r.name} [${status}]`,
` ID: ${r.id}`,
` Product: ${r.productId}`,
` Prep time: ${r.prepTimeMinutes} min`,
` Ingredients:`,
ingredientLines,
].join("\n");
});
return {
content: [
{
type: "text" as const,
text: `Found ${recipes.length} recipe(s):\n\n${lines.join("\n\n")}`,
},
],
};
} catch (error: any) {
const message = error.response?.data?.error?.message || error.message;
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true,
};
}
}
);
server.tool(
"create_recipe",
"Create a new recipe with ingredients list. Links to a product in the catalog.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
productId: z
.string()
.uuid()
.describe("The catalog product this recipe is for"),
name: z.string().describe("Recipe name"),
instructions: z
.string()
.optional()
.describe("Preparation instructions"),
prepTimeMinutes: z
.number()
.positive()
.default(5)
.describe("Preparation time in minutes (default 5)"),
ingredients: z
.array(
z.object({
ingredientName: z.string().describe("Name of the ingredient"),
quantity: z.number().describe("Quantity required"),
unit: z.string().describe("Unit of measurement (g, ml, pcs, etc.)"),
costPerUnit: z
.number()
.optional()
.describe("Cost per unit of ingredient"),
inventoryItemId: z
.string()
.uuid()
.optional()
.describe("Linked inventory item ID"),
})
)
.describe("List of ingredients for the recipe"),
},
async ({ shopId, productId, name, instructions, prepTimeMinutes, ingredients }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const response = await fnbApi.post(`/kitchen/recipes`, {
shopId: resolvedShopId,
productId,
name,
instructions,
prepTimeMinutes,
ingredients,
});
const recipeId = response.data?.data ?? response.data;
const ingredientSummary = ingredients
.map((ing) => ` - ${ing.ingredientName}: ${ing.quantity} ${ing.unit}`)
.join("\n");
return {
content: [
{
type: "text" as const,
text: [
`Recipe created successfully!`,
`ID: ${recipeId}`,
`Name: ${name}`,
`Product: ${productId}`,
`Prep time: ${prepTimeMinutes} min`,
`Ingredients (${ingredients.length}):`,
ingredientSummary,
].join("\n"),
},
],
};
} catch (error: any) {
const message = error.response?.data?.error?.message || error.message;
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true,
};
}
}
);
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}