fix: MCP server full audit — fix 4 critical + 8 high severity issues

CRITICAL fixes:
- update_product: fetch current product first, include productId in body (was 400)
- period enum: "week"/"month" → "7d"/"30d" to match backend handler
- Token leakage: add axios response interceptor to strip Authorization from errors
- Token expiry: add 401 detection with clear user-facing message

HIGH fixes:
- create_product: handle raw Guid response (was returning "unknown")
- update_product: merge with existing values to avoid overwriting with defaults
- Startup validation: warn if API_TOKEN is not set
- Graceful shutdown: handle SIGINT/SIGTERM with server.close()
- Error handler: shared module with structured API error extraction
- Type safety: replace `any` with proper DTO interfaces across all tools
- Promise.allSettled in cost_analysis for partial failure resilience
- Timeout increased 15s → 30s for analytics queries

MEDIUM fixes:
- amount fields use .int() (inventory backend expects int, not float)
- ingredients array requires .min(1) (prevent empty recipes)
- isActive default removed (show all products by default)
- pageSize default aligned to 20 (matches backend)
- String length limits on name/description fields
- Locale-explicit formatting (vi-VN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-20 14:14:49 +07:00
parent 20cf8781b8
commit 3c43ca519e
7 changed files with 379 additions and 363 deletions

View File

@@ -10,16 +10,25 @@
* Usage:
* claude mcp add --transport stdio goodgo -- node dist/index.js
*/
import dotenv from "dotenv";
dotenv.config();
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();
// Startup validation
if (!process.env.API_TOKEN) {
console.error(
"WARNING: API_TOKEN is not set. All API calls will fail with 401.\n" +
"Get a token: curl -s http://localhost/connect/token -d 'grant_type=password&username=...&password=...&client_id=password-client&client_secret=password-client-secret'\n" +
"Then add it to services/goodgo-mcp-server/.env"
);
}
const server = new McpServer({
name: "goodgo",
@@ -28,14 +37,14 @@ const server = new McpServer({
Available tool groups:
- Catalog: list/create/update/delete products and menu items
- Inventory: check stock, record intake (nhp kho), record usage (xut kho), low stock alerts
- Inventory: check stock, record intake (nhap kho), record usage (xuat 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đ).
All prices are in VND (Vietnamese Dong). Format as X.000d (e.g., 45.000d).
`,
});
@@ -45,8 +54,25 @@ registerInventoryTools(server);
registerRecipeTools(server);
registerAnalyticsTools(server);
// Start server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
// Graceful shutdown
async function shutdown() {
try {
await server.close();
} catch {
// ignore close errors
}
process.exit(0);
}
console.error("GoodGo MCP Server started (stdio transport)");
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Start server
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GoodGo MCP Server started (stdio transport)");
} catch (err) {
console.error("Failed to start MCP server:", err instanceof Error ? err.message : err);
process.exit(1);
}

View File

@@ -1,14 +1,11 @@
import axios, { type AxiosInstance } from "axios";
import dotenv from "dotenv";
dotenv.config();
/**
* All microservices run behind Traefik API Gateway on port 80.
* Traefik routes by path prefix:
* /api/v1/products, /api/v1/categories → catalog-service
* /api/v1/inventory, /api/v1/stock → inventory-service
* /api/v1/kitchen, /api/v1/tables → fnb-engine
* /api/v1/kitchen → fnb-engine
* /api/v1/orders → order-service
*/
const GATEWAY_URL = process.env.API_GATEWAY_URL || "http://localhost/api/v1";
@@ -16,10 +13,11 @@ const GATEWAY_URL = process.env.API_GATEWAY_URL || "http://localhost/api/v1";
function createClient(baseURL: string): AxiosInstance {
const client = axios.create({
baseURL,
timeout: 15000,
timeout: 30_000,
headers: { "Content-Type": "application/json" },
});
// Attach Bearer token
client.interceptors.request.use((config) => {
const token = process.env.API_TOKEN;
if (token) {
@@ -28,13 +26,24 @@ function createClient(baseURL: string): AxiosInstance {
return config;
});
// Strip auth headers from error objects to prevent token leakage
client.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.config?.headers) {
delete error.config.headers.Authorization;
}
return Promise.reject(error);
}
);
return client;
}
// Single gateway client — Traefik routes by path prefix
const gateway = createClient(GATEWAY_URL);
// Export aliases for backward compatibility (all point to same gateway)
// Export aliases (all point to same gateway)
export const catalogApi = gateway;
export const inventoryApi = gateway;
export const fnbApi = gateway;

View File

@@ -0,0 +1,49 @@
import axios from "axios";
/**
* Shared error handler for MCP tool responses.
* Extracts structured error messages from backend API responses
* while preventing token/internal URL leakage.
*/
export function errorResponse(err: unknown) {
let message = "An unexpected error occurred";
if (axios.isAxiosError(err)) {
// Extract structured error from backend ApiResponse
const apiError =
err.response?.data?.error?.message ??
err.response?.data?.error ??
err.response?.data?.Message ??
err.response?.data?.title;
if (apiError && typeof apiError === "string") {
message = apiError;
} else if (err.response?.status === 401) {
message = "Authentication failed — API token may be expired. Update API_TOKEN in .env";
} else if (err.response?.status === 403) {
message = "Access denied — insufficient permissions for this operation";
} else if (err.response?.status === 404) {
message = "Resource not found";
} else if (err.response?.status === 400) {
message = `Bad request: ${JSON.stringify(err.response?.data?.errors ?? err.response?.data) ?? "invalid input"}`;
} else if (err.code === "ECONNREFUSED" || err.code === "ECONNRESET") {
message = "Service unavailable — check that Docker services are running";
} else if (err.code === "ETIMEDOUT" || err.code === "ECONNABORTED") {
message = "Request timed out — service may be under heavy load";
} else {
// Fallback: use status text, never raw error.message (may contain internal URLs/tokens)
message = `Request failed with status ${err.response?.status ?? "unknown"}`;
}
} else if (err instanceof Error) {
message = err.message;
}
return {
content: [{ type: "text" as const, text: `Error: ${message}` }],
isError: true,
};
}
export function textResponse(text: string) {
return { content: [{ type: "text" as const, text }] };
}

View File

@@ -1,33 +1,30 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { orderApi, catalogApi, inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { orderApi, inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { errorResponse, textResponse } from "../services/error-handler.js";
interface TopItem {
interface PopularItemDto {
productName: string;
quantitySold: number;
revenue: number;
}
interface DashboardData {
// API returns "revenue" (not "totalRevenue")
interface PosDashboardDto {
revenue: number;
totalRevenue?: number;
orderCount: number;
itemsSold: number;
avgOrderValue: number;
// API returns "popularItems" (not "topItems")
popularItems?: TopItem[];
topItems?: TopItem[];
hourlyRevenue?: any[];
revenueByHour?: any[];
popularItems: PopularItemDto[];
hourlyRevenue: unknown[];
}
interface InventoryItem {
interface InventoryItemDto {
id: string;
name: string;
quantity: number;
costPerUnit: number;
sku?: string;
unit: string;
reorderLevel: number;
}
export function registerAnalyticsTools(server: McpServer): void {
@@ -41,69 +38,54 @@ export function registerAnalyticsTools(server: McpServer): void {
.optional()
.describe("Shop ID (defaults to configured shop)"),
period: z
.enum(["today", "week", "month"])
.default("week")
.describe("Time period to analyze (default: week)"),
.enum(["today", "7d", "30d"])
.default("7d")
.describe("Time period: today, 7d (last 7 days), 30d (last 30 days)"),
},
async ({ shopId, period }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const response = await orderApi.get(`/orders/dashboard`, {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await orderApi.get("/orders/dashboard", {
params: { shopId: resolvedShopId, period },
});
// Dashboard API may or may not wrap in {data: ...}
const raw = response.data?.data ?? response.data ?? {};
const dashboard: DashboardData = raw;
const topItems: TopItem[] = dashboard.popularItems ?? dashboard.topItems ?? [];
const totalRevenue = dashboard.revenue ?? dashboard.totalRevenue ?? 0;
// Dashboard API returns directly (no {success, data} wrapper)
const dashboard: PosDashboardDto = response.data?.data ?? response.data ?? {};
const topItems = dashboard.popularItems ?? [];
const totalRevenue = dashboard.revenue ?? 0;
if (topItems.length === 0 && totalRevenue === 0) {
return {
content: [
{
type: "text" as const,
text: `No sales data found for period: ${period}.`,
},
],
};
return textResponse(`No sales data found for period: ${period}.`);
}
const lines = topItems.map((item, i) => {
const rank = `#${i + 1}`;
const rev = (item.revenue ?? 0).toLocaleString();
return `${rank} ${item.productName}${item.quantitySold} sold (${rev} VND revenue)`;
const rev = (item.revenue ?? 0).toLocaleString("vi-VN");
return `${rank} ${item.productName}${item.quantitySold} sold (${rev} VND)`;
});
const summary = [
`Top Selling Products (${period})`,
`${"—".repeat(40)}`,
`Total Revenue: ${totalRevenue.toLocaleString()} VND`,
`Total Revenue: ${totalRevenue.toLocaleString("vi-VN")} VND`,
`Total Orders: ${dashboard.orderCount ?? 0}`,
`Items Sold: ${dashboard.itemsSold ?? 0}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString()} VND`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
``,
topItems.length > 0 ? `Rankings:` : `(No product rankings available)`,
...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,
};
return textResponse(summary);
} catch (err) {
return errorResponse(err);
}
}
);
server.tool(
"cost_analysis",
"Analyze cost structure by comparing inventory costs with revenue. Shows gross margin and cost breakdown.",
"Analyze cost structure by comparing inventory costs with revenue. Shows inventory value and revenue breakdown.",
{
shopId: z
.string()
@@ -113,84 +95,77 @@ export function registerAnalyticsTools(server: McpServer): void {
},
async ({ shopId }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const [inventoryResponse, dashboardResponse] = await Promise.all([
inventoryApi.get(`/inventory`, {
params: { shopId: resolvedShopId, take: 100 },
// Use Promise.allSettled for partial failure resilience
const [inventoryResult, dashboardResult] = await Promise.allSettled([
inventoryApi.get("/inventory", {
params: { shopId: resolvedShopId, take: 200 },
}),
orderApi.get(`/orders/dashboard`, {
params: { shopId: resolvedShopId, period: "month" },
orderApi.get("/orders/dashboard", {
params: { shopId: resolvedShopId, period: "30d" },
}),
]);
const inventoryItems: InventoryItem[] =
inventoryResponse.data?.data?.items ??
inventoryResponse.data?.data ??
inventoryResponse.data?.items ??
inventoryResponse.data ??
[];
// Extract inventory data
let inventoryItems: InventoryItemDto[] = [];
let inventoryError = "";
if (inventoryResult.status === "fulfilled") {
const payload = inventoryResult.value.data?.data ?? inventoryResult.value.data;
inventoryItems = payload?.items ?? [];
} else {
inventoryError = "Inventory data unavailable";
}
const dashboard: DashboardData =
dashboardResponse.data?.data ?? dashboardResponse.data ?? {};
// Extract dashboard data
let dashboard: Partial<PosDashboardDto> = {};
let dashboardError = "";
if (dashboardResult.status === "fulfilled") {
dashboard = dashboardResult.value.data?.data ?? dashboardResult.value.data ?? {};
} else {
dashboardError = "Revenue data unavailable";
}
const totalInventoryCost = Array.isArray(inventoryItems)
? inventoryItems.reduce(
const totalInventoryValue = inventoryItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 0),
0
)
: 0;
);
const totalRevenue = dashboard.revenue ?? dashboard.totalRevenue ?? 0;
const grossMargin =
totalRevenue > 0
? ((totalRevenue - totalInventoryCost) / totalRevenue) * 100
: 0;
const totalRevenue = dashboard.revenue ?? 0;
const costBreakdown = Array.isArray(inventoryItems)
? inventoryItems
const costBreakdown = inventoryItems
.filter((item) => item.costPerUnit > 0)
.sort(
(a, b) =>
b.quantity * b.costPerUnit - a.quantity * a.costPerUnit
)
.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`;
})
: [];
return ` - ${item.name}: ${item.quantity} ${item.unit} @ ${item.costPerUnit.toLocaleString("vi-VN")} = ${itemCost.toLocaleString("vi-VN")} VND`;
});
const warnings = [inventoryError, dashboardError].filter(Boolean);
const report = [
`Cost Analysis Report (Monthly)`,
`Cost Analysis Report (Last 30 Days)`,
`${"=".repeat(45)}`,
warnings.length > 0 ? `\nWarnings: ${warnings.join(", ")}\n` : "",
`Revenue (30d): ${totalRevenue.toLocaleString("vi-VN")} VND`,
`Inventory Value: ${totalInventoryValue.toLocaleString("vi-VN")} VND`,
``,
`Revenue: ${totalRevenue.toLocaleString()} VND`,
`Inventory Cost: ${totalInventoryCost.toLocaleString()} VND`,
`Gross Profit: ${(totalRevenue - totalInventoryCost).toLocaleString()} VND`,
`Gross Margin: ${grossMargin.toFixed(1)}%`,
`Orders (30d): ${dashboard.orderCount ?? "N/A"}`,
`Items Sold: ${dashboard.itemsSold ?? "N/A"}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
``,
`Orders This Month: ${dashboard.orderCount ?? 0}`,
`Items Sold: ${dashboard.itemsSold ?? 0}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString()} VND`,
`Top Cost Items (by current inventory value):`,
...(costBreakdown.length > 0 ? costBreakdown : [" (no cost data available)"]),
``,
`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");
`Inventory Items Tracked: ${inventoryItems.length}`,
]
.filter(Boolean)
.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,
};
return textResponse(report);
} catch (err) {
return errorResponse(err);
}
}
);

View File

@@ -1,10 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { catalogApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { errorResponse, textResponse } from "../services/error-handler.js";
function formatPrice(price: number): string {
return new Intl.NumberFormat("vi-VN", {
@@ -14,24 +11,25 @@ function formatPrice(price: number): string {
}).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 };
interface ProductDto {
id: string;
shopId: string;
name: string;
description?: string;
price: number;
type: string;
imageUrl?: string;
sku?: string;
barcode?: string;
categoryId?: string;
categoryName?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
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.",
@@ -49,8 +47,7 @@ export function registerCatalogTools(server: McpServer): void {
isActive: z
.boolean()
.optional()
.default(true)
.describe("Filter by active status"),
.describe("Filter by active status (omit to show all)"),
page: z.number().int().positive().optional().default(1),
pageSize: z
.number()
@@ -58,55 +55,39 @@ export function registerCatalogTools(server: McpServer): void {
.positive()
.max(200)
.optional()
.default(50),
.default(20),
},
async ({ shopId, categoryId, isActive, page, pageSize }) => {
try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const params: Record<string, unknown> = {
shopId: resolvedShopId,
isActive,
page,
pageSize,
};
if (isActive !== undefined) params.isActive = isActive;
if (categoryId) params.categoryId = categoryId;
const { data: res } = await catalogApi.get(
`/products`,
{ params },
);
const { data: res } = await catalogApi.get("/products", { params });
const items: any[] =
res?.data?.items ?? res?.data ?? res?.items ?? res ?? [];
// Response: { items: ProductDto[], totalCount, pageNumber, pageSize }
const items: ProductDto[] = res?.items ?? res?.data?.items ?? [];
const totalCount = res?.totalCount ?? res?.data?.totalCount ?? items.length;
if (!Array.isArray(items) || items.length === 0) {
return textResponse(
"No products found for this shop. (Khong tim thay san pham nao.)",
);
if (items.length === 0) {
return textResponse("No products found for this shop.");
}
// 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 rows = items.map((p) => {
const name = (p.name ?? "—").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 category = (p.categoryName ?? "—").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}):`,
"",
@@ -122,9 +103,7 @@ export function registerCatalogTools(server: McpServer): void {
},
);
// -----------------------------------------------------------------------
// 2. create_product
// -----------------------------------------------------------------------
server.tool(
"create_product",
"Create a new product/menu item in the shop catalog.",
@@ -134,8 +113,8 @@ export function registerCatalogTools(server: McpServer): void {
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
name: z.string().min(1).describe("Product name"),
description: z.string().optional().describe("Product description"),
name: z.string().min(1).max(200).describe("Product name"),
description: z.string().max(1000).optional().describe("Product description"),
price: z.number().positive().describe("Product price in VND"),
type: z
.string()
@@ -144,7 +123,7 @@ export function registerCatalogTools(server: McpServer): void {
.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"),
imageUrl: z.string().url().optional().describe("Image URL"),
},
async ({ shopId, name, description, price, type, categoryId, sku, imageUrl }) => {
try {
@@ -161,7 +140,11 @@ export function registerCatalogTools(server: McpServer): void {
const { data: res } = await catalogApi.post("/products", body);
const productId = res?.data?.id ?? res?.id ?? "unknown";
// Backend returns CreatedAtAction with raw Guid or { data: Guid }
const productId =
typeof res === "string" ? res :
typeof res?.data === "string" ? res.data :
res?.data?.id ?? res?.id ?? "unknown";
return textResponse(
`Product created successfully.\n` +
@@ -175,47 +158,62 @@ export function registerCatalogTools(server: McpServer): void {
},
);
// -----------------------------------------------------------------------
// 3. update_product
// -----------------------------------------------------------------------
server.tool(
"update_product",
"Update an existing product's name, price, description, or category.",
"Update an existing product's name, price, description, or category. Fetches current values first to avoid overwriting unchanged fields.",
{
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"),
name: z.string().min(1).max(200).optional().describe("New product name"),
description: z.string().max(1000).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"),
imageUrl: z.string().url().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.",
);
if (
name === undefined &&
description === undefined &&
price === undefined &&
categoryId === undefined &&
imageUrl === undefined
) {
return textResponse("No fields provided to update. Please specify at least one field.");
}
// Fetch current product to merge with partial updates
const { data: current } = await catalogApi.get(`/products/${productId}`);
const existing = current?.data ?? current;
const body: Record<string, unknown> = {
productId,
name: name ?? existing?.name ?? "",
price: price ?? existing?.price ?? 0,
description: description ?? existing?.description ?? "",
type: existing?.type ?? "PreparedFood",
shopId: existing?.shopId ?? DEFAULT_SHOP_ID,
};
if (categoryId !== undefined) body.categoryId = categoryId;
else if (existing?.categoryId) body.categoryId = existing.categoryId;
if (imageUrl !== undefined) body.imageUrl = imageUrl;
else if (existing?.imageUrl) body.imageUrl = existing.imageUrl;
await catalogApi.put(`/products/${productId}`, body);
const changes = Object.entries(body)
.map(([k, v]) => ` ${k}: ${k === "price" ? formatPrice(v as number) : v}`)
const changes: Record<string, unknown> = {};
if (name !== undefined) changes.name = name;
if (description !== undefined) changes.description = description;
if (price !== undefined) changes.price = formatPrice(price);
if (categoryId !== undefined) changes.categoryId = categoryId;
if (imageUrl !== undefined) changes.imageUrl = imageUrl;
const changeLines = Object.entries(changes)
.map(([k, v]) => ` ${k}: ${v}`)
.join("\n");
return textResponse(
`Product ${productId} updated successfully.\nUpdated fields:\n${changes}`,
`Product ${productId} updated successfully.\nUpdated fields:\n${changeLines}`,
);
} catch (err) {
return errorResponse(err);
@@ -223,21 +221,18 @@ export function registerCatalogTools(server: McpServer): void {
},
);
// -----------------------------------------------------------------------
// 4. delete_product
// -----------------------------------------------------------------------
server.tool(
"delete_product",
"Delete a product from the catalog.",
"Deactivate a product from the catalog (soft delete).",
{
productId: z.string().uuid().describe("Product ID to delete"),
productId: z.string().uuid().describe("Product ID to deactivate"),
},
async ({ productId }) => {
try {
await catalogApi.delete(`/products/${productId}`);
return textResponse(
`Product ${productId} deleted successfully. (San pham da bi xoa.)`,
`Product ${productId} deactivated successfully.`,
);
} catch (err) {
return errorResponse(err);

View File

@@ -1,9 +1,26 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { errorResponse, textResponse } from "../services/error-handler.js";
interface InventoryItemDto {
id: string;
productId: string;
shopId: string;
name: string;
itemType: string;
unit: string;
costPerUnit: number;
supplierName?: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
reorderLevel: number;
updatedAt?: string;
}
export function registerInventoryTools(server: McpServer): void {
// ─── 1. check_inventory ───────────────────────────────────────────────
// 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.",
@@ -12,40 +29,29 @@ export function registerInventoryTools(server: McpServer): void {
.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 resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await inventoryApi.get("/inventory", {
params: { shopId, skip, take },
params: { shopId: resolvedShopId, skip, take },
});
const payload = response.data.data;
const items: any[] = payload.items ?? [];
const totalCount: number = payload.totalCount ?? items.length;
const payload = response.data?.data ?? response.data;
const items: InventoryItemDto[] = 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}.`,
},
],
};
return textResponse(`No inventory items found for shop ${resolvedShopId}.`);
}
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 rows = items.map((item) => {
const status = item.quantity <= item.reorderLevel ? "\u26a0\ufe0f Low" : "\u2705 OK";
return `${item.name} | ${item.quantity} | ${item.unit} | ${item.reorderLevel} | ${status}`;
});
const table = [
@@ -57,18 +63,14 @@ export function registerInventoryTools(server: McpServer): void {
`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,
};
return textResponse(table);
} catch (err) {
return errorResponse(err);
}
}
);
// ─── 2. record_intake (nhap kho) ─────────────────────────────────────
// 2. record_intake (nhap kho)
server.tool(
"record_intake",
"Record stock intake (nhap kho) — when goods are received from suppliers.",
@@ -77,24 +79,23 @@ export function registerInventoryTools(server: McpServer): void {
.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"),
amount: z.number().int().positive().describe("Quantity to add (whole number)"),
notes: z.string().min(1).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,
shopId: shopId ?? DEFAULT_SHOP_ID,
amount,
notes,
unitCost,
notes: notes ?? undefined,
unitCost: unitCost ?? undefined,
});
const transactionId = response.data.data;
const inventoryItemId = response.data?.data ?? response.data;
const text = [
`Stock intake recorded successfully.`,
@@ -102,23 +103,19 @@ export function registerInventoryTools(server: McpServer): void {
` Amount: +${amount}`,
unitCost != null ? ` Unit Cost: ${unitCost}` : null,
notes ? ` Notes: ${notes}` : null,
` Transaction ID: ${transactionId}`,
` Inventory Item: ${inventoryItemId}`,
]
.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,
};
return textResponse(text);
} catch (err) {
return errorResponse(err);
}
}
);
// ─── 3. record_usage (xuat kho) ──────────────────────────────────────
// 3. record_usage (xuat kho)
server.tool(
"record_usage",
"Record stock usage/outflow (xuat kho) — when items are consumed, wasted, or adjusted.",
@@ -127,19 +124,18 @@ export function registerInventoryTools(server: McpServer): void {
.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)"),
amount: z.number().int().positive().describe("Quantity to remove (whole number)"),
notes: z.string().min(1).optional().describe("Optional notes (reason for usage)"),
},
async ({ shopId, productId, amount, notes }) => {
try {
await inventoryApi.post("/inventory/stock-out", {
productId,
shopId,
shopId: shopId ?? DEFAULT_SHOP_ID,
amount,
notes,
notes: notes ?? undefined,
});
const text = [
@@ -151,18 +147,14 @@ export function registerInventoryTools(server: McpServer): void {
.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,
};
return textResponse(text);
} catch (err) {
return errorResponse(err);
}
}
);
// ─── 4. low_stock_alerts ──────────────────────────────────────────────
// 4. low_stock_alerts
server.tool(
"low_stock_alerts",
"Get items that are at or below their reorder level. Critical for preventing stockouts.",
@@ -171,53 +163,37 @@ export function registerInventoryTools(server: McpServer): void {
.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 resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await inventoryApi.get("/inventory/low-stock", {
params: { shopId, skip, take },
params: { shopId: resolvedShopId, skip, take },
});
const payload = response.data.data;
const items: any[] = payload.items ?? [];
const totalCount: number = payload.totalCount ?? items.length;
const payload = response.data?.data ?? response.data;
const items: InventoryItemDto[] = 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.`,
},
],
};
return textResponse(
`No low-stock items found for shop ${resolvedShopId}. 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 rows = items.map((item) => {
const deficit = item.reorderLevel - item.quantity;
return `\u26a0\ufe0f ${item.name}: ${item.quantity} ${item.unit} (reorder at ${item.reorderLevel}, 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,
};
const text = [header, ...rows, "─".repeat(60), `Total low-stock items: ${totalCount}`].join("\n");
return textResponse(text);
} catch (err) {
return errorResponse(err);
}
}
);

View File

@@ -1,24 +1,28 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { fnbApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { errorResponse, textResponse } from "../services/error-handler.js";
interface RecipeIngredient {
interface RecipeIngredientDto {
id: string;
ingredientName: string;
quantity: number;
unit: string;
costPerUnit?: number;
costPerUnit: number;
inventoryItemId?: string;
quantityPerServing: number;
}
interface Recipe {
interface RecipeDto {
id: string;
productId: string;
shopId: string;
name: string;
instructions: string;
instructions?: string;
prepTimeMinutes: number;
isActive: boolean;
ingredients: RecipeIngredient[];
ingredients: RecipeIngredientDto[];
createdAt: string;
}
export function registerRecipeTools(server: McpServer): void {
@@ -34,22 +38,15 @@ export function registerRecipeTools(server: McpServer): void {
},
async ({ shopId }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const response = await fnbApi.get(`/kitchen/recipes`, {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await fnbApi.get("/kitchen/recipes", {
params: { shopId: resolvedShopId },
});
const recipes: Recipe[] = response.data?.data ?? response.data ?? [];
const recipes: RecipeDto[] = response.data?.data ?? response.data ?? [];
if (recipes.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No recipes found for this shop.",
},
],
};
if (!Array.isArray(recipes) || recipes.length === 0) {
return textResponse("No recipes found for this shop.");
}
const lines = recipes.map((r, i) => {
@@ -59,8 +56,8 @@ export function registerRecipeTools(server: McpServer): void {
? r.ingredients
.map((ing) => {
const cost =
ing.costPerUnit != null
? ` @ ${ing.costPerUnit.toLocaleString()}đ/unit`
ing.costPerUnit > 0
? ` @ ${ing.costPerUnit.toLocaleString("vi-VN")}d/unit`
: "";
return ` - ${ing.ingredientName}: ${ing.quantity} ${ing.unit}${cost}`;
})
@@ -77,20 +74,9 @@ export function registerRecipeTools(server: McpServer): void {
].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,
};
return textResponse(`Found ${recipes.length} recipe(s):\n\n${lines.join("\n\n")}`);
} catch (err) {
return errorResponse(err);
}
}
);
@@ -108,44 +94,53 @@ export function registerRecipeTools(server: McpServer): void {
.string()
.uuid()
.describe("The catalog product this recipe is for"),
name: z.string().describe("Recipe name"),
name: z.string().min(1).max(200).describe("Recipe name"),
instructions: z
.string()
.max(2000)
.optional()
.describe("Preparation instructions"),
prepTimeMinutes: z
.number()
.int()
.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.)"),
ingredientName: z.string().min(1).describe("Name of the ingredient"),
quantity: z.number().positive().describe("Quantity required"),
unit: z.string().min(1).describe("Unit of measurement (g, ml, pcs, etc.)"),
costPerUnit: z
.number()
.optional()
.describe("Cost per unit of ingredient"),
.min(0)
.default(0)
.describe("Cost per unit of ingredient (default 0)"),
inventoryItemId: z
.string()
.uuid()
.optional()
.describe("Linked inventory item ID"),
quantityPerServing: z
.number()
.min(0)
.default(0)
.describe("Quantity per serving for inventory deduction"),
})
)
.describe("List of ingredients for the recipe"),
.min(1)
.describe("List of ingredients for the recipe (at least 1)"),
},
async ({ shopId, productId, name, instructions, prepTimeMinutes, ingredients }) => {
try {
const resolvedShopId = shopId || DEFAULT_SHOP_ID;
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await fnbApi.post(`/kitchen/recipes`, {
const response = await fnbApi.post("/kitchen/recipes", {
shopId: resolvedShopId,
productId,
name,
instructions,
instructions: instructions ?? "",
prepTimeMinutes,
ingredients,
});
@@ -156,11 +151,8 @@ export function registerRecipeTools(server: McpServer): void {
.map((ing) => ` - ${ing.ingredientName}: ${ing.quantity} ${ing.unit}`)
.join("\n");
return {
content: [
{
type: "text" as const,
text: [
return textResponse(
[
`Recipe created successfully!`,
`ID: ${recipeId}`,
`Name: ${name}`,
@@ -169,15 +161,9 @@ export function registerRecipeTools(server: McpServer): void {
`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,
};
);
} catch (err) {
return errorResponse(err);
}
}
);