UNPKG

mcp-grocy

Version:

Model Context Protocol (MCP) server for Grocy integration

431 lines (430 loc) 21.6 kB
import { BaseToolHandler } from '../base.js'; import Fuse from 'fuse.js'; export class InventoryToolHandlers extends BaseToolHandler { // ==================== PRODUCT MANAGEMENT ==================== getProducts = async (args) => { return this.executeToolHandler(async () => { const { fields } = args || {}; // Validate required parameters this.validateRequired({ fields }, ['fields']); const fieldList = this.parseArrayParam(fields, 'fields'); // Fetch products const products = await this.apiCall('/objects/products'); if (!Array.isArray(products)) { return this.createSuccess([]); } // Filter to only include requested fields const filteredProducts = this.filterFields(products, fieldList); return this.createSuccess(filteredProducts); }); }; getProductGroups = async () => { return this.executeToolHandler(async () => { const data = await this.apiCall('/objects/product_groups'); return this.createSuccess(data); }); }; getPriceHistory = async (args) => { return this.executeToolHandler(async () => { const { productId } = args || {}; this.validateRequired({ productId }, ['productId']); const data = await this.apiCall(`/stock/products/${productId}/price-history`); return this.createSuccess(data); }); }; // ==================== STOCK QUERIES ==================== getAllStock = async () => { return this.executeToolHandler(async () => { const data = await this.apiCall('/stock'); return this.createSuccess(data); }); }; getStockByProduct = async (args) => { return this.executeToolHandler(async () => { const { productId } = args || {}; this.validateRequired({ productId }, ['productId']); const stockEntries = await this.apiCall(`/stock/products/${productId}/entries`); // Define essential fields for stock entries const entryFields = [ 'id', 'amount', 'best_before_date', 'purchased_date', 'stock_id', 'note', 'location_id' ]; // Filter entries to only include essential fields const filteredEntries = Array.isArray(stockEntries) ? this.filterFields(stockEntries, entryFields) : []; return this.createSuccess(filteredEntries); }); }; getStockVolatile = async (args) => { return this.executeToolHandler(async () => { const queryParams = args?.includeDetails ? { include_details: 'true' } : {}; const data = await this.apiCall('/stock/volatile', 'GET', undefined, { queryParams }); return this.createSuccess(data); }); }; getStockByLocation = async (args) => { return this.executeToolHandler(async () => { const { locationId } = args || {}; this.validateRequired({ locationId }, ['locationId']); const data = await this.apiCall(`stock`, 'GET', undefined, { queryParams: { 'query[]': `location_id=${locationId}` } }); return this.createSuccess(data); }); }; // ==================== STOCK TRANSACTIONS ==================== purchaseProduct = async (args) => { return this.executeToolHandler(async () => { const { productId, amount, bestBeforeDate, price, locationId, note } = args || {}; this.validateRequired({ productId, amount }, ['productId', 'amount']); const body = { amount }; if (bestBeforeDate) body.best_before_date = bestBeforeDate; if (price !== undefined) body.price = price; if (locationId) body.location_id = locationId; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${productId}/add`, 'POST', body); return this.createSuccess(result, 'Product purchased successfully'); }); }; consumeProduct = async (args) => { return this.executeToolHandler(async () => { const { productId, amount, spoiled = false, locationId, note } = args || {}; this.validateRequired({ productId, amount }, ['productId', 'amount']); const body = { amount, transaction_type: spoiled ? 'inventory-correction' : 'consume' }; if (locationId) body.location_id = locationId; if (note) body.note = note; if (spoiled) body.spoiled = true; const result = await this.apiCall(`/stock/products/${productId}/consume`, 'POST', body); return this.createSuccess(result, 'Product consumed successfully'); }); }; transferProduct = async (args) => { return this.executeToolHandler(async () => { const { productId, amount, fromLocationId, toLocationId, note } = args || {}; this.validateRequired({ productId, amount, fromLocationId, toLocationId }, ['productId', 'amount', 'fromLocationId', 'toLocationId']); const body = { amount, location_id_from: fromLocationId, location_id_to: toLocationId }; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${productId}/transfer`, 'POST', body); return this.createSuccess(result, 'Product transferred successfully'); }); }; inventoryProduct = async (args) => { return this.executeToolHandler(async () => { const { productId, newAmount, bestBeforeDate, locationId, note } = args || {}; this.validateRequired({ productId, newAmount }, ['productId', 'newAmount']); const body = { new_amount: newAmount }; if (bestBeforeDate) body.best_before_date = bestBeforeDate; if (locationId) body.location_id = locationId; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${productId}/inventory`, 'POST', body); return this.createSuccess(result, 'Product inventory updated successfully'); }); }; openProduct = async (args) => { return this.executeToolHandler(async () => { const { productId, amount = 1, note } = args || {}; this.validateRequired({ productId }, ['productId']); const body = { amount }; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${productId}/open`, 'POST', body); return this.createSuccess(result, 'Product opened successfully'); }); }; lookupProduct = async (args) => { return this.executeToolHandler(async () => { const { productName } = args || {}; this.validateRequired({ productName }, ['productName']); const [productsResponse, locationsResponse, quantityUnitsResponse] = await Promise.all([ this.apiCall('/objects/products'), this.apiCall('/objects/locations'), this.apiCall('/objects/quantity_units') ]); const products = Array.isArray(productsResponse) ? productsResponse : []; const locations = Array.isArray(locationsResponse) ? locationsResponse : []; const quantityUnits = Array.isArray(quantityUnitsResponse) ? quantityUnitsResponse : []; const fuseOptions = { keys: [ { name: 'name', weight: 1.0 }, { name: 'description', weight: 0.3 } ], threshold: 0.6, distance: 100, minMatchCharLength: 1, ignoreLocation: true, includeScore: true, includeMatches: true, useExtendedSearch: false, isCaseSensitive: false, shouldSort: true, findAllMatches: false }; const fuse = new Fuse(products, fuseOptions); const fuseResults = fuse.search(productName); let productMatches = fuseResults.map((result) => ({ ...result.item, matchScore: Math.round((1 - result.score) * 100), fuseScore: result.score, matches: result.matches })); if (productMatches.length === 0) { const permissiveFuse = new Fuse(products, { ...fuseOptions, threshold: 0.8, distance: 200 }); const permissiveResults = permissiveFuse.search(productName); productMatches = permissiveResults.map((result) => ({ ...result.item, matchScore: Math.round((1 - result.score) * 100), fuseScore: result.score, matches: result.matches, isPermissiveMatch: true })); } productMatches = productMatches.slice(0, 5); if (productMatches.length === 0) { return this.createError(`No products found matching "${productName}"`, { suggestion: 'Try a different product name or check the spelling', availableProducts: products.slice(0, 10).map((p) => p.name) }); } const enrichedMatches = await Promise.all(productMatches.map(async (product) => { let productEntries = []; try { productEntries = await this.apiCall(`/stock/products/${product.id}/entries`); productEntries = Array.isArray(productEntries) ? productEntries : []; } catch { productEntries = []; } const stockEntries = productEntries.map((stockItem) => { return { amount: stockItem.amount, bestBeforeDate: stockItem.best_before_date, stockId: stockItem.id, locationId: parseInt(stockItem.location_id) }; }); stockEntries.sort((a, b) => { if (!a.bestBeforeDate && !b.bestBeforeDate) return 0; if (!a.bestBeforeDate) return 1; if (!b.bestBeforeDate) return -1; return new Date(a.bestBeforeDate).getTime() - new Date(b.bestBeforeDate).getTime(); }); const unit = quantityUnits.find((u) => u.id == product.qu_id_stock); const unitInfo = unit ? { id: unit.id, name: unit.name } : { id: null, name: 'pieces' }; const hasMultipleLocations = stockEntries.length > 1 && new Set(stockEntries.map((entry) => entry.locationId)).size > 1; const locationInstructions = hasMultipleLocations ? 'IMPORTANT: This product has stock in multiple locations. Make sure the user requested a specific location or confirm the locationId before performing any operations.' : undefined; return { productId: product.id, productName: product.name, stockEntries: stockEntries, totalStockAmount: productEntries.reduce((sum, s) => sum + parseFloat(s.amount || 0), 0), unit: unitInfo, locationInstructions }; })); return this.createSuccess({ message: `Found ${enrichedMatches.length} product matches for "${productName}" (ordered from most likely to least likely match)`, productMatches: enrichedMatches, allAvailableLocations: locations.map((l) => ({ id: l.id, name: l.name })), instructions: 'Review the matches above. Use the exact productId and locationId from this data for any product operations (inventory_stock_entry_consume, inventory_stock_entry_transfer, inventory_transactions_purchase, inventory_transactions_adjust, etc.).' }); }); }; printProductLabel = async (args) => { return this.executeToolHandler(async () => { const { productId } = args || {}; this.validateRequired({ productId }, ['productId']); const result = await this.apiCall(`/stock/products/${productId}/printlabel`); return this.createSuccess(result, 'Product label printed successfully'); }); }; printStockEntryLabel = async (args) => { return this.executeToolHandler(async () => { const { stockId, productId } = args || {}; this.validateRequired({ stockId, productId }, ['stockId', 'productId']); const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`); if (!stockEntryResponse || !stockEntryResponse.product_id) { throw new Error(`Could not resolve product ID from stock entry ${stockId}`); } if (stockEntryResponse.product_id !== productId) { throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`); } const result = await this.apiCall(`/stock/entry/${stockId}/printlabel`); return this.createSuccess(result, 'Stock entry label printed successfully'); }); }; // ==================== GRANULAR STOCK ENTRY OPERATIONS ==================== async splitStockEntry(originalEntry, stockAmounts, getUnitForm) { const splitEntries = []; if (stockAmounts.length === 1) { const amount = stockAmounts[0]; if (typeof amount !== 'number' || amount <= 0) { throw new Error(`Invalid amount: ${amount}`); } const note = `${originalEntry.note || ''} - ${originalEntry.id} - 1`; await this.apiCall(`/stock/entry/${originalEntry.id}`, 'PUT', { amount: amount, open: false, note: note, best_before_date: originalEntry.best_before_date, purchased_date: originalEntry.purchased_date, location_id: originalEntry.location_id }); splitEntries.push({ stockId: originalEntry.id, amount: amount, type: 'updated', unit: getUnitForm(amount) }); } else { for (let i = 0; i < stockAmounts.length; i++) { const amount = stockAmounts[i]; if (typeof amount !== 'number' || amount <= 0) { throw new Error(`Invalid amount at index ${i}: ${amount}`); } const note = `${originalEntry.note || ''} - ${originalEntry.id} - ${i + 1}`; if (i === 0) { await this.apiCall(`/stock/entry/${originalEntry.id}`, 'PUT', { amount: amount, open: false, note: note, best_before_date: originalEntry.best_before_date, purchased_date: originalEntry.purchased_date, location_id: originalEntry.location_id }); splitEntries.push({ stockId: originalEntry.id, amount, type: 'updated', unit: getUnitForm(amount) }); } else { const createResponse = await this.apiCall(`/stock/products/${originalEntry.product_id}/add`, 'POST', { amount, best_before_date: originalEntry.best_before_date, purchased_date: originalEntry.purchased_date, transaction_type: 'purchase', location_id: originalEntry.location_id, note: note }); const stockId = createResponse[0].stock_id || createResponse[0].id; const stockResponse = await this.apiCall('/objects/stock'); const stockEntries = Array.isArray(stockResponse) ? stockResponse : []; const actualStockEntry = stockEntries.find((entry) => entry.product_id === originalEntry.product_id && entry.stock_id === stockId); if (!actualStockEntry) { throw new Error(`Could not find created stock entry with product_id ${originalEntry.product_id} and stock_id ${stockId}`); } splitEntries.push({ stockId: actualStockEntry.id, amount, type: 'created', unit: getUnitForm(amount) }); } } } return splitEntries; } consumeStockEntry = async (args) => { return this.executeToolHandler(async () => { const { stockId, productId, amount, spoiled = false, note } = args || {}; this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']); const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`); if (!stockEntryResponse || !stockEntryResponse.product_id) { throw new Error(`Could not resolve product ID from stock entry ${stockId}`); } if (stockEntryResponse.product_id !== productId) { throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`); } const body = { amount, spoiled, stock_entry_id: stockEntryResponse.stock_id, location_id: stockEntryResponse.location_id }; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/consume`, 'POST', body); return this.createSuccess(result, 'Stock entry consumed successfully'); }); }; transferStockEntry = async (args) => { return this.executeToolHandler(async () => { const { stockId, productId, amount, locationIdTo, note } = args || {}; this.validateRequired({ stockId, productId, amount, locationIdTo }, ['stockId', 'productId', 'amount', 'locationIdTo']); const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`); if (!stockEntryResponse || !stockEntryResponse.product_id) { throw new Error(`Could not resolve product ID from stock entry ${stockId}`); } if (stockEntryResponse.product_id !== productId) { throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`); } const body = { amount, location_id_from: stockEntryResponse.location_id, location_id_to: locationIdTo, transaction_type: 'transfer', stock_entry_id: stockEntryResponse.stock_id }; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/transfer`, 'POST', body); return this.createSuccess(result, 'Stock entry transferred successfully'); }); }; openStockEntry = async (args) => { return this.executeToolHandler(async () => { const { stockId, productId, amount, note } = args || {}; this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']); const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`); if (!stockEntryResponse || !stockEntryResponse.product_id) { throw new Error(`Could not resolve product ID from stock entry ${stockId}`); } if (stockEntryResponse.product_id !== productId) { throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`); } const body = { amount, stock_entry_id: stockEntryResponse.stock_id, location_id: stockEntryResponse.location_id }; if (note) body.note = note; const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/open`, 'POST', body); return this.createSuccess(result, 'Stock entry opened successfully'); }); }; }