From 3c43ca519e267123735280215a9a3c7258b8fc8e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 20 Mar 2026 14:14:49 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20MCP=20server=20full=20audit=20=E2=80=94?= =?UTF-8?q?=20fix=204=20critical=20+=208=20high=20severity=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/goodgo-mcp-server/src/index.ts | 42 +++- .../src/services/api-client.ts | 21 +- .../src/services/error-handler.ts | 49 +++++ .../src/tools/analytics-tools.ts | 189 ++++++++---------- .../src/tools/catalog-tools.ts | 163 ++++++++------- .../src/tools/inventory-tools.ts | 162 +++++++-------- .../src/tools/recipe-tools.ts | 116 +++++------ 7 files changed, 379 insertions(+), 363 deletions(-) create mode 100644 services/goodgo-mcp-server/src/services/error-handler.ts diff --git a/services/goodgo-mcp-server/src/index.ts b/services/goodgo-mcp-server/src/index.ts index 293770fb..ce9b0584 100644 --- a/services/goodgo-mcp-server/src/index.ts +++ b/services/goodgo-mcp-server/src/index.ts @@ -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 (nhập kho), record usage (xuất 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); +} diff --git a/services/goodgo-mcp-server/src/services/api-client.ts b/services/goodgo-mcp-server/src/services/api-client.ts index 86a73542..ddf25ecd 100644 --- a/services/goodgo-mcp-server/src/services/api-client.ts +++ b/services/goodgo-mcp-server/src/services/api-client.ts @@ -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; diff --git a/services/goodgo-mcp-server/src/services/error-handler.ts b/services/goodgo-mcp-server/src/services/error-handler.ts new file mode 100644 index 00000000..bc5c6cc7 --- /dev/null +++ b/services/goodgo-mcp-server/src/services/error-handler.ts @@ -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 }] }; +} diff --git a/services/goodgo-mcp-server/src/tools/analytics-tools.ts b/services/goodgo-mcp-server/src/tools/analytics-tools.ts index cf195af3..677c2c07 100644 --- a/services/goodgo-mcp-server/src/tools/analytics-tools.ts +++ b/services/goodgo-mcp-server/src/tools/analytics-tools.ts @@ -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 = {}; + 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( - (sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 0), - 0 - ) - : 0; + const totalInventoryValue = inventoryItems.reduce( + (sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 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 - .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 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 (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); } } ); diff --git a/services/goodgo-mcp-server/src/tools/catalog-tools.ts b/services/goodgo-mcp-server/src/tools/catalog-tools.ts index c33a9060..c3d7fc01 100644 --- a/services/goodgo-mcp-server/src/tools/catalog-tools.ts +++ b/services/goodgo-mcp-server/src/tools/catalog-tools.ts @@ -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 = { 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 = {}; - 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 = { + 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 = {}; + 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); diff --git a/services/goodgo-mcp-server/src/tools/inventory-tools.ts b/services/goodgo-mcp-server/src/tools/inventory-tools.ts index 99cfc8b7..c8e775fb 100644 --- a/services/goodgo-mcp-server/src/tools/inventory-tools.ts +++ b/services/goodgo-mcp-server/src/tools/inventory-tools.ts @@ -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,48 +79,43 @@ 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.`, - ` Product: ${productId}`, - ` Amount: +${amount}`, - unitCost != null ? ` Unit Cost: ${unitCost}` : null, - notes ? ` Notes: ${notes}` : null, - ` Transaction ID: ${transactionId}`, + ` Product: ${productId}`, + ` Amount: +${amount}`, + unitCost != null ? ` Unit Cost: ${unitCost}` : null, + notes ? ` Notes: ${notes}` : null, + ` 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); } } ); diff --git a/services/goodgo-mcp-server/src/tools/recipe-tools.ts b/services/goodgo-mcp-server/src/tools/recipe-tools.ts index 93416e2d..4fca8937 100644 --- a/services/goodgo-mcp-server/src/tools/recipe-tools.ts +++ b/services/goodgo-mcp-server/src/tools/recipe-tools.ts @@ -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,28 +151,19 @@ export function registerRecipeTools(server: McpServer): void { .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, - }; + return textResponse( + [ + `Recipe created successfully!`, + `ID: ${recipeId}`, + `Name: ${name}`, + `Product: ${productId}`, + `Prep time: ${prepTimeMinutes} min`, + `Ingredients (${ingredients.length}):`, + ingredientSummary, + ].join("\n"), + ); + } catch (err) { + return errorResponse(err); } } );