From b7a194f14b434fb685ea64344244d5db43f50bde Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 15 Mar 2026 12:55:58 +0700 Subject: [PATCH] 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 --- services/goodgo-mcp-server/.env.example | 11 + services/goodgo-mcp-server/package.json | 23 ++ services/goodgo-mcp-server/src/index.ts | 52 ++++ .../src/services/api-client.ts | 41 +++ .../src/tools/analytics-tools.ts | 190 ++++++++++++++ .../src/tools/catalog-tools.ts | 246 ++++++++++++++++++ .../src/tools/inventory-tools.ts | 224 ++++++++++++++++ .../src/tools/recipe-tools.ts | 184 +++++++++++++ services/goodgo-mcp-server/tsconfig.json | 19 ++ 9 files changed, 990 insertions(+) create mode 100644 services/goodgo-mcp-server/.env.example create mode 100644 services/goodgo-mcp-server/package.json create mode 100644 services/goodgo-mcp-server/src/index.ts create mode 100644 services/goodgo-mcp-server/src/services/api-client.ts create mode 100644 services/goodgo-mcp-server/src/tools/analytics-tools.ts create mode 100644 services/goodgo-mcp-server/src/tools/catalog-tools.ts create mode 100644 services/goodgo-mcp-server/src/tools/inventory-tools.ts create mode 100644 services/goodgo-mcp-server/src/tools/recipe-tools.ts create mode 100644 services/goodgo-mcp-server/tsconfig.json diff --git a/services/goodgo-mcp-server/.env.example b/services/goodgo-mcp-server/.env.example new file mode 100644 index 00000000..3ed48770 --- /dev/null +++ b/services/goodgo-mcp-server/.env.example @@ -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= diff --git a/services/goodgo-mcp-server/package.json b/services/goodgo-mcp-server/package.json new file mode 100644 index 00000000..b0a923fa --- /dev/null +++ b/services/goodgo-mcp-server/package.json @@ -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" + } +} diff --git a/services/goodgo-mcp-server/src/index.ts b/services/goodgo-mcp-server/src/index.ts new file mode 100644 index 00000000..293770fb --- /dev/null +++ b/services/goodgo-mcp-server/src/index.ts @@ -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)"); diff --git a/services/goodgo-mcp-server/src/services/api-client.ts b/services/goodgo-mcp-server/src/services/api-client.ts new file mode 100644 index 00000000..ceb8fef3 --- /dev/null +++ b/services/goodgo-mcp-server/src/services/api-client.ts @@ -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"; diff --git a/services/goodgo-mcp-server/src/tools/analytics-tools.ts b/services/goodgo-mcp-server/src/tools/analytics-tools.ts new file mode 100644 index 00000000..da467106 --- /dev/null +++ b/services/goodgo-mcp-server/src/tools/analytics-tools.ts @@ -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, + }; + } + } + ); +} diff --git a/services/goodgo-mcp-server/src/tools/catalog-tools.ts b/services/goodgo-mcp-server/src/tools/catalog-tools.ts new file mode 100644 index 00000000..d9348234 --- /dev/null +++ b/services/goodgo-mcp-server/src/tools/catalog-tools.ts @@ -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 = { + 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 = { + 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 = {}; + 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); + } + }, + ); +} diff --git a/services/goodgo-mcp-server/src/tools/inventory-tools.ts b/services/goodgo-mcp-server/src/tools/inventory-tools.ts new file mode 100644 index 00000000..99cfc8b7 --- /dev/null +++ b/services/goodgo-mcp-server/src/tools/inventory-tools.ts @@ -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, + }; + } + } + ); +} diff --git a/services/goodgo-mcp-server/src/tools/recipe-tools.ts b/services/goodgo-mcp-server/src/tools/recipe-tools.ts new file mode 100644 index 00000000..93416e2d --- /dev/null +++ b/services/goodgo-mcp-server/src/tools/recipe-tools.ts @@ -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, + }; + } + } + ); +} diff --git a/services/goodgo-mcp-server/tsconfig.json b/services/goodgo-mcp-server/tsconfig.json new file mode 100644 index 00000000..cc487cc9 --- /dev/null +++ b/services/goodgo-mcp-server/tsconfig.json @@ -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"] +}