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:
11
services/goodgo-mcp-server/.env.example
Normal file
11
services/goodgo-mcp-server/.env.example
Normal 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=
|
||||
23
services/goodgo-mcp-server/package.json
Normal file
23
services/goodgo-mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
services/goodgo-mcp-server/src/index.ts
Normal file
52
services/goodgo-mcp-server/src/index.ts
Normal 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)");
|
||||
41
services/goodgo-mcp-server/src/services/api-client.ts
Normal file
41
services/goodgo-mcp-server/src/services/api-client.ts
Normal 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";
|
||||
190
services/goodgo-mcp-server/src/tools/analytics-tools.ts
Normal file
190
services/goodgo-mcp-server/src/tools/analytics-tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
246
services/goodgo-mcp-server/src/tools/catalog-tools.ts
Normal file
246
services/goodgo-mcp-server/src/tools/catalog-tools.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
224
services/goodgo-mcp-server/src/tools/inventory-tools.ts
Normal file
224
services/goodgo-mcp-server/src/tools/inventory-tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
184
services/goodgo-mcp-server/src/tools/recipe-tools.ts
Normal file
184
services/goodgo-mcp-server/src/tools/recipe-tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
19
services/goodgo-mcp-server/tsconfig.json
Normal file
19
services/goodgo-mcp-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user