Files
pos-system/services/goodgo-mcp-server/src/tools/analytics-tools.ts
Ho Ngoc Hai 3c43ca519e 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>
2026-03-20 14:14:49 +07:00

173 lines
5.9 KiB
TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { orderApi, inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { errorResponse, textResponse } from "../services/error-handler.js";
interface PopularItemDto {
productName: string;
quantitySold: number;
revenue: number;
}
interface PosDashboardDto {
revenue: number;
orderCount: number;
itemsSold: number;
avgOrderValue: number;
popularItems: PopularItemDto[];
hourlyRevenue: unknown[];
}
interface InventoryItemDto {
id: string;
name: string;
quantity: number;
costPerUnit: number;
unit: string;
reorderLevel: number;
}
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", "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", {
params: { shopId: resolvedShopId, period },
});
// 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 textResponse(`No sales data found for period: ${period}.`);
}
const lines = topItems.map((item, i) => {
const rank = `#${i + 1}`;
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("vi-VN")} VND`,
`Total Orders: ${dashboard.orderCount ?? 0}`,
`Items Sold: ${dashboard.itemsSold ?? 0}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
``,
topItems.length > 0 ? `Rankings:` : `(No product rankings available)`,
...lines,
].join("\n");
return textResponse(summary);
} catch (err) {
return errorResponse(err);
}
}
);
server.tool(
"cost_analysis",
"Analyze cost structure by comparing inventory costs with revenue. Shows inventory value and revenue breakdown.",
{
shopId: z
.string()
.uuid()
.optional()
.describe("Shop ID (defaults to configured shop)"),
},
async ({ shopId }) => {
try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
// 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: "30d" },
}),
]);
// 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";
}
// 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 totalInventoryValue = inventoryItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 0),
0
);
const totalRevenue = dashboard.revenue ?? 0;
const costBreakdown = 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} ${item.unit} @ ${item.costPerUnit.toLocaleString("vi-VN")} = ${itemCost.toLocaleString("vi-VN")} VND`;
});
const warnings = [inventoryError, dashboardError].filter(Boolean);
const report = [
`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`,
``,
`Orders (30d): ${dashboard.orderCount ?? "N/A"}`,
`Items Sold: ${dashboard.itemsSold ?? "N/A"}`,
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
``,
`Top Cost Items (by current inventory value):`,
...(costBreakdown.length > 0 ? costBreakdown : [" (no cost data available)"]),
``,
`Inventory Items Tracked: ${inventoryItems.length}`,
]
.filter(Boolean)
.join("\n");
return textResponse(report);
} catch (err) {
return errorResponse(err);
}
}
);
}