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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
services/goodgo-mcp-server/src/services/error-handler.ts
Normal file
49
services/goodgo-mcp-server/src/services/error-handler.ts
Normal 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 }] };
|
||||
}
|
||||
@@ -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(
|
||||
(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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user