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

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

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

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

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

View File

@@ -10,16 +10,25 @@
* Usage: * Usage:
* claude mcp add --transport stdio goodgo -- node dist/index.js * 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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import dotenv from "dotenv";
import { registerCatalogTools } from "./tools/catalog-tools.js"; import { registerCatalogTools } from "./tools/catalog-tools.js";
import { registerInventoryTools } from "./tools/inventory-tools.js"; import { registerInventoryTools } from "./tools/inventory-tools.js";
import { registerRecipeTools } from "./tools/recipe-tools.js"; import { registerRecipeTools } from "./tools/recipe-tools.js";
import { registerAnalyticsTools } from "./tools/analytics-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({ const server = new McpServer({
name: "goodgo", name: "goodgo",
@@ -28,14 +37,14 @@ const server = new McpServer({
Available tool groups: Available tool groups:
- Catalog: list/create/update/delete products and menu items - Catalog: list/create/update/delete products and menu items
- Inventory: check stock, record intake (nhp kho), record usage (xut kho), low stock alerts - Inventory: check stock, record intake (nhap kho), record usage (xuat kho), low stock alerts
- Recipes: list and create recipes with ingredients - Recipes: list and create recipes with ingredients
- Analytics: popular items, cost analysis - Analytics: popular items, cost analysis
Default shop: Cobic Coffee (${process.env.DEFAULT_SHOP_ID || "e1f392af-fe95-4c7f-8656-5b74ad5fd0a9"}) Default shop: Cobic Coffee (${process.env.DEFAULT_SHOP_ID || "e1f392af-fe95-4c7f-8656-5b74ad5fd0a9"})
If user doesn't specify shopId, use the default. 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); registerRecipeTools(server);
registerAnalyticsTools(server); registerAnalyticsTools(server);
// Start server with stdio transport // Graceful shutdown
const transport = new StdioServerTransport(); async function shutdown() {
await server.connect(transport); try {
await server.close();
} catch {
// ignore close errors
}
process.exit(0);
}
console.error("GoodGo MCP Server started (stdio transport)"); process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Start server
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GoodGo MCP Server started (stdio transport)");
} catch (err) {
console.error("Failed to start MCP server:", err instanceof Error ? err.message : err);
process.exit(1);
}

View File

@@ -1,14 +1,11 @@
import axios, { type AxiosInstance } from "axios"; import axios, { type AxiosInstance } from "axios";
import dotenv from "dotenv";
dotenv.config();
/** /**
* All microservices run behind Traefik API Gateway on port 80. * All microservices run behind Traefik API Gateway on port 80.
* Traefik routes by path prefix: * Traefik routes by path prefix:
* /api/v1/products, /api/v1/categories → catalog-service * /api/v1/products, /api/v1/categories → catalog-service
* /api/v1/inventory, /api/v1/stock → inventory-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 * /api/v1/orders → order-service
*/ */
const GATEWAY_URL = process.env.API_GATEWAY_URL || "http://localhost/api/v1"; 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 { function createClient(baseURL: string): AxiosInstance {
const client = axios.create({ const client = axios.create({
baseURL, baseURL,
timeout: 15000, timeout: 30_000,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
// Attach Bearer token
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = process.env.API_TOKEN; const token = process.env.API_TOKEN;
if (token) { if (token) {
@@ -28,13 +26,24 @@ function createClient(baseURL: string): AxiosInstance {
return config; 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; return client;
} }
// Single gateway client — Traefik routes by path prefix // Single gateway client — Traefik routes by path prefix
const gateway = createClient(GATEWAY_URL); 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 catalogApi = gateway;
export const inventoryApi = gateway; export const inventoryApi = gateway;
export const fnbApi = gateway; export const fnbApi = gateway;

View File

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

View File

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

View File

@@ -1,10 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { catalogApi, DEFAULT_SHOP_ID } from "../services/api-client.js"; import { catalogApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
import { errorResponse, textResponse } from "../services/error-handler.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatPrice(price: number): string { function formatPrice(price: number): string {
return new Intl.NumberFormat("vi-VN", { return new Intl.NumberFormat("vi-VN", {
@@ -14,24 +11,25 @@ function formatPrice(price: number): string {
}).format(price); }).format(price);
} }
function errorResponse(err: unknown) { interface ProductDto {
const message = id: string;
err instanceof Error ? err.message : "Unknown error occurred"; shopId: string;
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; 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 { export function registerCatalogTools(server: McpServer): void {
// -----------------------------------------------------------------------
// 1. list_products // 1. list_products
// -----------------------------------------------------------------------
server.tool( server.tool(
"list_products", "list_products",
"List menu items/products for a shop. Returns name, price, category, stock status.", "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 isActive: z
.boolean() .boolean()
.optional() .optional()
.default(true) .describe("Filter by active status (omit to show all)"),
.describe("Filter by active status"),
page: z.number().int().positive().optional().default(1), page: z.number().int().positive().optional().default(1),
pageSize: z pageSize: z
.number() .number()
@@ -58,55 +55,39 @@ export function registerCatalogTools(server: McpServer): void {
.positive() .positive()
.max(200) .max(200)
.optional() .optional()
.default(50), .default(20),
}, },
async ({ shopId, categoryId, isActive, page, pageSize }) => { async ({ shopId, categoryId, isActive, page, pageSize }) => {
try { try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID; const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
shopId: resolvedShopId, shopId: resolvedShopId,
isActive,
page, page,
pageSize, pageSize,
}; };
if (isActive !== undefined) params.isActive = isActive;
if (categoryId) params.categoryId = categoryId; if (categoryId) params.categoryId = categoryId;
const { data: res } = await catalogApi.get( const { data: res } = await catalogApi.get("/products", { params });
`/products`,
{ params },
);
const items: any[] = // Response: { items: ProductDto[], totalCount, pageNumber, pageSize }
res?.data?.items ?? res?.data ?? res?.items ?? res ?? []; const items: ProductDto[] = res?.items ?? res?.data?.items ?? [];
const totalCount = res?.totalCount ?? res?.data?.totalCount ?? items.length;
if (!Array.isArray(items) || items.length === 0) { if (items.length === 0) {
return textResponse( return textResponse("No products found for this shop.");
"No products found for this shop. (Khong tim thay san pham nao.)",
);
} }
// Build a text table
const header = `${"Name".padEnd(30)} | ${"Price".padStart(14)} | ${"Category".padEnd(20)} | Active`; const header = `${"Name".padEnd(30)} | ${"Price".padStart(14)} | ${"Category".padEnd(20)} | Active`;
const separator = "-".repeat(header.length); const separator = "-".repeat(header.length);
const rows = items.map((p: any) => { const rows = items.map((p) => {
const name = (p.name ?? "—").toString().slice(0, 29).padEnd(30); const name = (p.name ?? "—").slice(0, 29).padEnd(30);
const price = formatPrice(p.price ?? 0).padStart(14); const price = formatPrice(p.price ?? 0).padStart(14);
const category = ( const category = (p.categoryName ?? "—").slice(0, 19).padEnd(20);
p.categoryName ??
p.category ??
"—"
)
.toString()
.slice(0, 19)
.padEnd(20);
const active = p.isActive !== false ? "Yes" : "No"; const active = p.isActive !== false ? "Yes" : "No";
return `${name} | ${price} | ${category} | ${active}`; return `${name} | ${price} | ${category} | ${active}`;
}); });
const totalCount =
res?.data?.totalCount ?? res?.totalCount ?? items.length;
const output = [ const output = [
`Products for shop ${resolvedShopId} (page ${page}, showing ${items.length} of ${totalCount}):`, `Products for shop ${resolvedShopId} (page ${page}, showing ${items.length} of ${totalCount}):`,
"", "",
@@ -122,9 +103,7 @@ export function registerCatalogTools(server: McpServer): void {
}, },
); );
// -----------------------------------------------------------------------
// 2. create_product // 2. create_product
// -----------------------------------------------------------------------
server.tool( server.tool(
"create_product", "create_product",
"Create a new product/menu item in the shop catalog.", "Create a new product/menu item in the shop catalog.",
@@ -134,8 +113,8 @@ export function registerCatalogTools(server: McpServer): void {
.uuid() .uuid()
.optional() .optional()
.describe("Shop ID (defaults to configured shop)"), .describe("Shop ID (defaults to configured shop)"),
name: z.string().min(1).describe("Product name"), name: z.string().min(1).max(200).describe("Product name"),
description: z.string().optional().describe("Product description"), description: z.string().max(1000).optional().describe("Product description"),
price: z.number().positive().describe("Product price in VND"), price: z.number().positive().describe("Product price in VND"),
type: z type: z
.string() .string()
@@ -144,7 +123,7 @@ export function registerCatalogTools(server: McpServer): void {
.describe("Product type (e.g. PreparedFood, Beverage, RetailItem)"), .describe("Product type (e.g. PreparedFood, Beverage, RetailItem)"),
categoryId: z.string().uuid().optional().describe("Category ID"), categoryId: z.string().uuid().optional().describe("Category ID"),
sku: z.string().optional().describe("Stock-keeping unit code"), 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 }) => { async ({ shopId, name, description, price, type, categoryId, sku, imageUrl }) => {
try { try {
@@ -161,7 +140,11 @@ export function registerCatalogTools(server: McpServer): void {
const { data: res } = await catalogApi.post("/products", body); 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( return textResponse(
`Product created successfully.\n` + `Product created successfully.\n` +
@@ -175,47 +158,62 @@ export function registerCatalogTools(server: McpServer): void {
}, },
); );
// -----------------------------------------------------------------------
// 3. update_product // 3. update_product
// -----------------------------------------------------------------------
server.tool( server.tool(
"update_product", "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"), productId: z.string().uuid().describe("Product ID to update"),
name: z.string().optional().describe("New product name"), name: z.string().min(1).max(200).optional().describe("New product name"),
description: z.string().optional().describe("New description"), description: z.string().max(1000).optional().describe("New description"),
price: z price: z.number().positive().optional().describe("New price in VND"),
.number()
.positive()
.optional()
.describe("New price in VND"),
categoryId: z.string().uuid().optional().describe("New category ID"), 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 }) => { async ({ productId, name, description, price, categoryId, imageUrl }) => {
try { try {
const body: Record<string, unknown> = {}; if (
if (name !== undefined) body.name = name; name === undefined &&
if (description !== undefined) body.description = description; description === undefined &&
if (price !== undefined) body.price = price; price === undefined &&
if (categoryId !== undefined) body.categoryId = categoryId; categoryId === undefined &&
if (imageUrl !== undefined) body.imageUrl = imageUrl; imageUrl === undefined
) {
if (Object.keys(body).length === 0) { return textResponse("No fields provided to update. Please specify at least one field.");
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); await catalogApi.put(`/products/${productId}`, body);
const changes = Object.entries(body) const changes: Record<string, unknown> = {};
.map(([k, v]) => ` ${k}: ${k === "price" ? formatPrice(v as number) : v}`) 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"); .join("\n");
return textResponse( return textResponse(
`Product ${productId} updated successfully.\nUpdated fields:\n${changes}`, `Product ${productId} updated successfully.\nUpdated fields:\n${changeLines}`,
); );
} catch (err) { } catch (err) {
return errorResponse(err); return errorResponse(err);
@@ -223,21 +221,18 @@ export function registerCatalogTools(server: McpServer): void {
}, },
); );
// -----------------------------------------------------------------------
// 4. delete_product // 4. delete_product
// -----------------------------------------------------------------------
server.tool( server.tool(
"delete_product", "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 }) => { async ({ productId }) => {
try { try {
await catalogApi.delete(`/products/${productId}`); await catalogApi.delete(`/products/${productId}`);
return textResponse( return textResponse(
`Product ${productId} deleted successfully. (San pham da bi xoa.)`, `Product ${productId} deactivated successfully.`,
); );
} catch (err) { } catch (err) {
return errorResponse(err); return errorResponse(err);

View File

@@ -1,9 +1,26 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js"; 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 { export function registerInventoryTools(server: McpServer): void {
// ─── 1. check_inventory ─────────────────────────────────────────────── // 1. check_inventory
server.tool( server.tool(
"check_inventory", "check_inventory",
"Check current inventory/stock levels for a shop. Shows item name, quantity, unit, reorder level, and low stock warnings.", "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() .string()
.uuid() .uuid()
.optional() .optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"), .describe("Shop ID (defaults to primary shop)"),
skip: z.number().int().min(0).optional().default(0).describe("Records to skip"), 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"), take: z.number().int().min(1).max(200).optional().default(50).describe("Records to take"),
}, },
async ({ shopId, skip, take }) => { async ({ shopId, skip, take }) => {
try { try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await inventoryApi.get("/inventory", { const response = await inventoryApi.get("/inventory", {
params: { shopId, skip, take }, params: { shopId: resolvedShopId, skip, take },
}); });
const payload = response.data.data; const payload = response.data?.data ?? response.data;
const items: any[] = payload.items ?? []; const items: InventoryItemDto[] = payload?.items ?? [];
const totalCount: number = payload.totalCount ?? items.length; const totalCount: number = payload?.totalCount ?? items.length;
if (items.length === 0) { if (items.length === 0) {
return { return textResponse(`No inventory items found for shop ${resolvedShopId}.`);
content: [
{
type: "text" as const,
text: `No inventory items found for shop ${shopId}.`,
},
],
};
} }
const header = `Inventory (${items.length} of ${totalCount} items)\n${"─".repeat(70)}`; const header = `Inventory (${items.length} of ${totalCount} items)\n${"─".repeat(70)}`;
const rows = items.map((item: any) => { const rows = items.map((item) => {
const qty = item.quantity ?? item.currentStock ?? 0; const status = item.quantity <= item.reorderLevel ? "\u26a0\ufe0f Low" : "\u2705 OK";
const reorder = item.reorderLevel ?? item.reorderPoint ?? 0; return `${item.name} | ${item.quantity} | ${item.unit} | ${item.reorderLevel} | ${status}`;
const status = qty <= reorder ? "\u26a0\ufe0f Low" : "\u2705 OK";
const name = item.itemName ?? item.productName ?? item.name ?? "Unknown";
const unit = item.unit ?? item.unitOfMeasure ?? "-";
return `${name} | ${qty} | ${unit} | ${reorder} | ${status}`;
}); });
const table = [ const table = [
@@ -57,18 +63,14 @@ export function registerInventoryTools(server: McpServer): void {
`Page: skip=${skip}, take=${take} | Total: ${totalCount}`, `Page: skip=${skip}, take=${take} | Total: ${totalCount}`,
].join("\n"); ].join("\n");
return { content: [{ type: "text" as const, text: table }] }; return textResponse(table);
} catch (error: any) { } catch (err) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error); return errorResponse(err);
return {
content: [{ type: "text" as const, text: `Error checking inventory: ${msg}` }],
isError: true,
};
} }
} }
); );
// ─── 2. record_intake (nhap kho) ───────────────────────────────────── // 2. record_intake (nhap kho)
server.tool( server.tool(
"record_intake", "record_intake",
"Record stock intake (nhap kho) — when goods are received from suppliers.", "Record stock intake (nhap kho) — when goods are received from suppliers.",
@@ -77,48 +79,43 @@ export function registerInventoryTools(server: McpServer): void {
.string() .string()
.uuid() .uuid()
.optional() .optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"), .describe("Shop ID (defaults to primary shop)"),
productId: z.string().uuid().describe("Product ID to stock in"), productId: z.string().uuid().describe("Product ID to stock in"),
amount: z.number().positive().describe("Quantity to add"), amount: z.number().int().positive().describe("Quantity to add (whole number)"),
notes: z.string().optional().describe("Optional notes for this intake"), notes: z.string().min(1).optional().describe("Optional notes for this intake"),
unitCost: z.number().positive().optional().describe("Cost per unit (optional)"), unitCost: z.number().positive().optional().describe("Cost per unit (optional)"),
}, },
async ({ shopId, productId, amount, notes, unitCost }) => { async ({ shopId, productId, amount, notes, unitCost }) => {
try { try {
const response = await inventoryApi.post("/inventory/stock-in", { const response = await inventoryApi.post("/inventory/stock-in", {
productId, productId,
shopId, shopId: shopId ?? DEFAULT_SHOP_ID,
amount, amount,
notes, notes: notes ?? undefined,
unitCost, unitCost: unitCost ?? undefined,
}); });
const transactionId = response.data.data; const inventoryItemId = response.data?.data ?? response.data;
const text = [ const text = [
`Stock intake recorded successfully.`, `Stock intake recorded successfully.`,
` Product: ${productId}`, ` Product: ${productId}`,
` Amount: +${amount}`, ` Amount: +${amount}`,
unitCost != null ? ` Unit Cost: ${unitCost}` : null, unitCost != null ? ` Unit Cost: ${unitCost}` : null,
notes ? ` Notes: ${notes}` : null, notes ? ` Notes: ${notes}` : null,
` Transaction ID: ${transactionId}`, ` Inventory Item: ${inventoryItemId}`,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
return { content: [{ type: "text" as const, text }] }; return textResponse(text);
} catch (error: any) { } catch (err) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error); return errorResponse(err);
return {
content: [{ type: "text" as const, text: `Error recording intake: ${msg}` }],
isError: true,
};
} }
} }
); );
// ─── 3. record_usage (xuat kho) ────────────────────────────────────── // 3. record_usage (xuat kho)
server.tool( server.tool(
"record_usage", "record_usage",
"Record stock usage/outflow (xuat kho) — when items are consumed, wasted, or adjusted.", "Record stock usage/outflow (xuat kho) — when items are consumed, wasted, or adjusted.",
@@ -127,19 +124,18 @@ export function registerInventoryTools(server: McpServer): void {
.string() .string()
.uuid() .uuid()
.optional() .optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"), .describe("Shop ID (defaults to primary shop)"),
productId: z.string().uuid().describe("Product ID to stock out"), productId: z.string().uuid().describe("Product ID to stock out"),
amount: z.number().positive().describe("Quantity to remove"), amount: z.number().int().positive().describe("Quantity to remove (whole number)"),
notes: z.string().optional().describe("Optional notes (reason for usage)"), notes: z.string().min(1).optional().describe("Optional notes (reason for usage)"),
}, },
async ({ shopId, productId, amount, notes }) => { async ({ shopId, productId, amount, notes }) => {
try { try {
await inventoryApi.post("/inventory/stock-out", { await inventoryApi.post("/inventory/stock-out", {
productId, productId,
shopId, shopId: shopId ?? DEFAULT_SHOP_ID,
amount, amount,
notes, notes: notes ?? undefined,
}); });
const text = [ const text = [
@@ -151,18 +147,14 @@ export function registerInventoryTools(server: McpServer): void {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
return { content: [{ type: "text" as const, text }] }; return textResponse(text);
} catch (error: any) { } catch (err) {
const msg = error.response?.data?.error?.message ?? error.message ?? String(error); return errorResponse(err);
return {
content: [{ type: "text" as const, text: `Error recording usage: ${msg}` }],
isError: true,
};
} }
} }
); );
// ─── 4. low_stock_alerts ────────────────────────────────────────────── // 4. low_stock_alerts
server.tool( server.tool(
"low_stock_alerts", "low_stock_alerts",
"Get items that are at or below their reorder level. Critical for preventing stockouts.", "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() .string()
.uuid() .uuid()
.optional() .optional()
.default(DEFAULT_SHOP_ID)
.describe("Shop ID (defaults to primary shop)"), .describe("Shop ID (defaults to primary shop)"),
skip: z.number().int().min(0).optional().default(0).describe("Records to skip"), 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"), take: z.number().int().min(1).max(200).optional().default(50).describe("Records to take"),
}, },
async ({ shopId, skip, take }) => { async ({ shopId, skip, take }) => {
try { try {
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
const response = await inventoryApi.get("/inventory/low-stock", { const response = await inventoryApi.get("/inventory/low-stock", {
params: { shopId, skip, take }, params: { shopId: resolvedShopId, skip, take },
}); });
const payload = response.data.data; const payload = response.data?.data ?? response.data;
const items: any[] = payload.items ?? []; const items: InventoryItemDto[] = payload?.items ?? [];
const totalCount: number = payload.totalCount ?? items.length; const totalCount: number = payload?.totalCount ?? items.length;
if (items.length === 0) { if (items.length === 0) {
return { return textResponse(
content: [ `No low-stock items found for shop ${resolvedShopId}. All stock levels are healthy.`,
{ );
type: "text" as const,
text: `No low-stock items found for shop ${shopId}. All stock levels are healthy.`,
},
],
};
} }
const header = `\u26a0\ufe0f Low Stock Alerts (${items.length} of ${totalCount} items)\n${"─".repeat(60)}`; const header = `\u26a0\ufe0f Low Stock Alerts (${items.length} of ${totalCount} items)\n${"─".repeat(60)}`;
const rows = items.map((item: any) => { const rows = items.map((item) => {
const name = item.itemName ?? item.productName ?? item.name ?? "Unknown"; const deficit = item.reorderLevel - item.quantity;
const qty = item.quantity ?? item.currentStock ?? 0; return `\u26a0\ufe0f ${item.name}: ${item.quantity} ${item.unit} (reorder at ${item.reorderLevel}, need ${deficit > 0 ? deficit : 0} more)`;
const reorder = item.reorderLevel ?? item.reorderPoint ?? 0;
const unit = item.unit ?? item.unitOfMeasure ?? "-";
const deficit = reorder - qty;
return `\u26a0\ufe0f ${name}: ${qty} ${unit} (reorder at ${reorder}, need ${deficit > 0 ? deficit : 0} more)`;
}); });
const text = [header, ...rows, "─".repeat(60), `Total low-stock items: ${totalCount}`].join( const text = [header, ...rows, "─".repeat(60), `Total low-stock items: ${totalCount}`].join("\n");
"\n" return textResponse(text);
); } catch (err) {
return errorResponse(err);
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,
};
} }
} }
); );

View File

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