mcp-grocy 2.2.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +112 -32
  3. package/build/api/client.js +11 -13
  4. package/build/config/index.js +74 -20
  5. package/build/resources/CHANGELOG.md +31 -0
  6. package/build/resources/DOCS.md +8 -3
  7. package/build/resources/README.md +112 -32
  8. package/build/resources/api-reference.md +88 -88
  9. package/build/resources/config.md +30 -17
  10. package/build/resources/examples.md +120 -221
  11. package/build/resources/installation.md +9 -14
  12. package/build/resources/response-format.md +18 -13
  13. package/build/server/http-server.js +154 -42
  14. package/build/server/mcp-server.js +143 -96
  15. package/build/server/resources.js +37 -24
  16. package/build/server/tool-input-zod.js +86 -0
  17. package/build/tools/base.js +19 -15
  18. package/build/tools/household/definitions.js +47 -43
  19. package/build/tools/household/handlers.js +2 -2
  20. package/build/tools/household/index.js +2 -2
  21. package/build/tools/inventory/definitions.js +150 -113
  22. package/build/tools/inventory/handlers.js +55 -42
  23. package/build/tools/inventory/index.js +2 -2
  24. package/build/tools/module-loader.js +15 -12
  25. package/build/tools/recipes/definitions.js +103 -86
  26. package/build/tools/recipes/handlers.js +44 -38
  27. package/build/tools/recipes/index.js +3 -3
  28. package/build/tools/recipes/validations.js +8 -2
  29. package/build/tools/shopping/definitions.js +22 -20
  30. package/build/tools/shopping/handlers.js +1 -1
  31. package/build/tools/shopping/index.js +2 -2
  32. package/build/tools/system/definitions.js +25 -22
  33. package/build/tools/system/handlers.js +74 -12
  34. package/build/tools/system/index.js +2 -2
  35. package/build/tools/validation-helpers.js +10 -7
  36. package/build/types/index.js +12 -10
  37. package/build/utils/errors.js +10 -5
  38. package/build/utils/logger.js +16 -15
  39. package/build/version.js +2 -2
  40. package/package.json +38 -23
@@ -1,4 +1,5 @@
1
1
  import { BaseToolHandler } from '../base.js';
2
+ import { ValidationError } from '../../utils/errors.js';
2
3
  import Fuse from 'fuse.js';
3
4
  export class InventoryToolHandlers extends BaseToolHandler {
4
5
  // ==================== PRODUCT MANAGEMENT ====================
@@ -46,8 +47,13 @@ export class InventoryToolHandlers extends BaseToolHandler {
46
47
  const stockEntries = await this.apiCall(`/stock/products/${productId}/entries`);
47
48
  // Define essential fields for stock entries
48
49
  const entryFields = [
49
- 'id', 'amount', 'best_before_date', 'purchased_date',
50
- 'stock_id', 'note', 'location_id'
50
+ 'id',
51
+ 'amount',
52
+ 'best_before_date',
53
+ 'purchased_date',
54
+ 'stock_id',
55
+ 'note',
56
+ 'location_id',
51
57
  ];
52
58
  // Filter entries to only include essential fields
53
59
  const filteredEntries = Array.isArray(stockEntries)
@@ -68,7 +74,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
68
74
  const { locationId } = args || {};
69
75
  this.validateRequired({ locationId }, ['locationId']);
70
76
  const data = await this.apiCall(`stock`, 'GET', undefined, {
71
- queryParams: { 'query[]': `location_id=${locationId}` }
77
+ queryParams: { 'query[]': `location_id=${locationId}` },
72
78
  });
73
79
  return this.createSuccess(data);
74
80
  });
@@ -97,7 +103,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
97
103
  this.validateRequired({ productId, amount }, ['productId', 'amount']);
98
104
  const body = {
99
105
  amount,
100
- transaction_type: spoiled ? 'inventory-correction' : 'consume'
106
+ transaction_type: spoiled ? 'inventory-correction' : 'consume',
101
107
  };
102
108
  if (locationId)
103
109
  body.location_id = locationId;
@@ -112,11 +118,16 @@ export class InventoryToolHandlers extends BaseToolHandler {
112
118
  transferProduct = async (args) => {
113
119
  return this.executeToolHandler(async () => {
114
120
  const { productId, amount, fromLocationId, toLocationId, note } = args || {};
115
- this.validateRequired({ productId, amount, fromLocationId, toLocationId }, ['productId', 'amount', 'fromLocationId', 'toLocationId']);
121
+ this.validateRequired({ productId, amount, fromLocationId, toLocationId }, [
122
+ 'productId',
123
+ 'amount',
124
+ 'fromLocationId',
125
+ 'toLocationId',
126
+ ]);
116
127
  const body = {
117
128
  amount,
118
129
  location_id_from: fromLocationId,
119
- location_id_to: toLocationId
130
+ location_id_to: toLocationId,
120
131
  };
121
132
  if (note)
122
133
  body.note = note;
@@ -157,7 +168,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
157
168
  const [productsResponse, locationsResponse, quantityUnitsResponse] = await Promise.all([
158
169
  this.apiCall('/objects/products'),
159
170
  this.apiCall('/objects/locations'),
160
- this.apiCall('/objects/quantity_units')
171
+ this.apiCall('/objects/quantity_units'),
161
172
  ]);
162
173
  const products = Array.isArray(productsResponse) ? productsResponse : [];
163
174
  const locations = Array.isArray(locationsResponse) ? locationsResponse : [];
@@ -165,7 +176,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
165
176
  const fuseOptions = {
166
177
  keys: [
167
178
  { name: 'name', weight: 1.0 },
168
- { name: 'description', weight: 0.3 }
179
+ { name: 'description', weight: 0.3 },
169
180
  ],
170
181
  threshold: 0.6,
171
182
  distance: 100,
@@ -176,7 +187,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
176
187
  useExtendedSearch: false,
177
188
  isCaseSensitive: false,
178
189
  shouldSort: true,
179
- findAllMatches: false
190
+ findAllMatches: false,
180
191
  };
181
192
  const fuse = new Fuse(products, fuseOptions);
182
193
  const fuseResults = fuse.search(productName);
@@ -184,13 +195,13 @@ export class InventoryToolHandlers extends BaseToolHandler {
184
195
  ...result.item,
185
196
  matchScore: Math.round((1 - result.score) * 100),
186
197
  fuseScore: result.score,
187
- matches: result.matches
198
+ matches: result.matches,
188
199
  }));
189
200
  if (productMatches.length === 0) {
190
201
  const permissiveFuse = new Fuse(products, {
191
202
  ...fuseOptions,
192
203
  threshold: 0.8,
193
- distance: 200
204
+ distance: 200,
194
205
  });
195
206
  const permissiveResults = permissiveFuse.search(productName);
196
207
  productMatches = permissiveResults.map((result) => ({
@@ -198,14 +209,14 @@ export class InventoryToolHandlers extends BaseToolHandler {
198
209
  matchScore: Math.round((1 - result.score) * 100),
199
210
  fuseScore: result.score,
200
211
  matches: result.matches,
201
- isPermissiveMatch: true
212
+ isPermissiveMatch: true,
202
213
  }));
203
214
  }
204
215
  productMatches = productMatches.slice(0, 5);
205
216
  if (productMatches.length === 0) {
206
217
  return this.createError(`No products found matching "${productName}"`, {
207
218
  suggestion: 'Try a different product name or check the spelling',
208
- availableProducts: products.slice(0, 10).map((p) => p.name)
219
+ availableProducts: products.slice(0, 10).map((p) => p.name),
209
220
  });
210
221
  }
211
222
  const enrichedMatches = await Promise.all(productMatches.map(async (product) => {
@@ -222,7 +233,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
222
233
  amount: stockItem.amount,
223
234
  bestBeforeDate: stockItem.best_before_date,
224
235
  stockId: stockItem.id,
225
- locationId: parseInt(stockItem.location_id)
236
+ locationId: parseInt(stockItem.location_id),
226
237
  };
227
238
  });
228
239
  stockEntries.sort((a, b) => {
@@ -235,9 +246,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
235
246
  return new Date(a.bestBeforeDate).getTime() - new Date(b.bestBeforeDate).getTime();
236
247
  });
237
248
  const unit = quantityUnits.find((u) => u.id == product.qu_id_stock);
238
- const unitInfo = unit
239
- ? { id: unit.id, name: unit.name }
240
- : { id: null, name: 'pieces' };
249
+ const unitInfo = unit ? { id: unit.id, name: unit.name } : { id: null, name: 'pieces' };
241
250
  const hasMultipleLocations = stockEntries.length > 1 &&
242
251
  new Set(stockEntries.map((entry) => entry.locationId)).size > 1;
243
252
  const locationInstructions = hasMultipleLocations
@@ -249,14 +258,14 @@ export class InventoryToolHandlers extends BaseToolHandler {
249
258
  stockEntries: stockEntries,
250
259
  totalStockAmount: productEntries.reduce((sum, s) => sum + parseFloat(s.amount || 0), 0),
251
260
  unit: unitInfo,
252
- locationInstructions
261
+ locationInstructions,
253
262
  };
254
263
  }));
255
264
  return this.createSuccess({
256
265
  message: `Found ${enrichedMatches.length} product matches for "${productName}" (ordered from most likely to least likely match)`,
257
266
  productMatches: enrichedMatches,
258
267
  allAvailableLocations: locations.map((l) => ({ id: l.id, name: l.name })),
259
- 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.).'
268
+ 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.).',
260
269
  });
261
270
  });
262
271
  };
@@ -274,10 +283,10 @@ export class InventoryToolHandlers extends BaseToolHandler {
274
283
  this.validateRequired({ stockId, productId }, ['stockId', 'productId']);
275
284
  const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
276
285
  if (!stockEntryResponse || !stockEntryResponse.product_id) {
277
- throw new Error(`Could not resolve product ID from stock entry ${stockId}`);
286
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'printStockEntryLabel');
278
287
  }
279
288
  if (stockEntryResponse.product_id !== productId) {
280
- throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`);
289
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'printStockEntryLabel');
281
290
  }
282
291
  const result = await this.apiCall(`/stock/entry/${stockId}/printlabel`);
283
292
  return this.createSuccess(result, 'Stock entry label printed successfully');
@@ -289,7 +298,7 @@ export class InventoryToolHandlers extends BaseToolHandler {
289
298
  if (stockAmounts.length === 1) {
290
299
  const amount = stockAmounts[0];
291
300
  if (typeof amount !== 'number' || amount <= 0) {
292
- throw new Error(`Invalid amount: ${amount}`);
301
+ throw new ValidationError(`Invalid amount: ${amount}`, 'splitStockEntry');
293
302
  }
294
303
  const note = `${originalEntry.note || ''} - ${originalEntry.id} - 1`;
295
304
  await this.apiCall(`/stock/entry/${originalEntry.id}`, 'PUT', {
@@ -298,20 +307,20 @@ export class InventoryToolHandlers extends BaseToolHandler {
298
307
  note: note,
299
308
  best_before_date: originalEntry.best_before_date,
300
309
  purchased_date: originalEntry.purchased_date,
301
- location_id: originalEntry.location_id
310
+ location_id: originalEntry.location_id,
302
311
  });
303
312
  splitEntries.push({
304
313
  stockId: originalEntry.id,
305
314
  amount: amount,
306
315
  type: 'updated',
307
- unit: getUnitForm(amount)
316
+ unit: getUnitForm(amount),
308
317
  });
309
318
  }
310
319
  else {
311
320
  for (let i = 0; i < stockAmounts.length; i++) {
312
321
  const amount = stockAmounts[i];
313
322
  if (typeof amount !== 'number' || amount <= 0) {
314
- throw new Error(`Invalid amount at index ${i}: ${amount}`);
323
+ throw new ValidationError(`Invalid amount at index ${i}: ${amount}`, 'splitStockEntry');
315
324
  }
316
325
  const note = `${originalEntry.note || ''} - ${originalEntry.id} - ${i + 1}`;
317
326
  if (i === 0) {
@@ -321,13 +330,13 @@ export class InventoryToolHandlers extends BaseToolHandler {
321
330
  note: note,
322
331
  best_before_date: originalEntry.best_before_date,
323
332
  purchased_date: originalEntry.purchased_date,
324
- location_id: originalEntry.location_id
333
+ location_id: originalEntry.location_id,
325
334
  });
326
335
  splitEntries.push({
327
336
  stockId: originalEntry.id,
328
337
  amount,
329
338
  type: 'updated',
330
- unit: getUnitForm(amount)
339
+ unit: getUnitForm(amount),
331
340
  });
332
341
  }
333
342
  else {
@@ -337,21 +346,20 @@ export class InventoryToolHandlers extends BaseToolHandler {
337
346
  purchased_date: originalEntry.purchased_date,
338
347
  transaction_type: 'purchase',
339
348
  location_id: originalEntry.location_id,
340
- note: note
349
+ note: note,
341
350
  });
342
351
  const stockId = createResponse[0].stock_id || createResponse[0].id;
343
352
  const stockResponse = await this.apiCall('/objects/stock');
344
353
  const stockEntries = Array.isArray(stockResponse) ? stockResponse : [];
345
- const actualStockEntry = stockEntries.find((entry) => entry.product_id === originalEntry.product_id &&
346
- entry.stock_id === stockId);
354
+ const actualStockEntry = stockEntries.find((entry) => entry.product_id === originalEntry.product_id && entry.stock_id === stockId);
347
355
  if (!actualStockEntry) {
348
- throw new Error(`Could not find created stock entry with product_id ${originalEntry.product_id} and stock_id ${stockId}`);
356
+ throw new ValidationError(`Could not find created stock entry with product_id ${originalEntry.product_id} and stock_id ${stockId}`, 'splitStockEntry');
349
357
  }
350
358
  splitEntries.push({
351
359
  stockId: actualStockEntry.id,
352
360
  amount,
353
361
  type: 'created',
354
- unit: getUnitForm(amount)
362
+ unit: getUnitForm(amount),
355
363
  });
356
364
  }
357
365
  }
@@ -364,16 +372,16 @@ export class InventoryToolHandlers extends BaseToolHandler {
364
372
  this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']);
365
373
  const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
366
374
  if (!stockEntryResponse || !stockEntryResponse.product_id) {
367
- throw new Error(`Could not resolve product ID from stock entry ${stockId}`);
375
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'consumeStockEntry');
368
376
  }
369
377
  if (stockEntryResponse.product_id !== productId) {
370
- throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`);
378
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'consumeStockEntry');
371
379
  }
372
380
  const body = {
373
381
  amount,
374
382
  spoiled,
375
383
  stock_entry_id: stockEntryResponse.stock_id,
376
- location_id: stockEntryResponse.location_id
384
+ location_id: stockEntryResponse.location_id,
377
385
  };
378
386
  if (note)
379
387
  body.note = note;
@@ -384,20 +392,25 @@ export class InventoryToolHandlers extends BaseToolHandler {
384
392
  transferStockEntry = async (args) => {
385
393
  return this.executeToolHandler(async () => {
386
394
  const { stockId, productId, amount, locationIdTo, note } = args || {};
387
- this.validateRequired({ stockId, productId, amount, locationIdTo }, ['stockId', 'productId', 'amount', 'locationIdTo']);
395
+ this.validateRequired({ stockId, productId, amount, locationIdTo }, [
396
+ 'stockId',
397
+ 'productId',
398
+ 'amount',
399
+ 'locationIdTo',
400
+ ]);
388
401
  const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
389
402
  if (!stockEntryResponse || !stockEntryResponse.product_id) {
390
- throw new Error(`Could not resolve product ID from stock entry ${stockId}`);
403
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'transferStockEntry');
391
404
  }
392
405
  if (stockEntryResponse.product_id !== productId) {
393
- throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`);
406
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'transferStockEntry');
394
407
  }
395
408
  const body = {
396
409
  amount,
397
410
  location_id_from: stockEntryResponse.location_id,
398
411
  location_id_to: locationIdTo,
399
412
  transaction_type: 'transfer',
400
- stock_entry_id: stockEntryResponse.stock_id
413
+ stock_entry_id: stockEntryResponse.stock_id,
401
414
  };
402
415
  if (note)
403
416
  body.note = note;
@@ -411,15 +424,15 @@ export class InventoryToolHandlers extends BaseToolHandler {
411
424
  this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']);
412
425
  const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
413
426
  if (!stockEntryResponse || !stockEntryResponse.product_id) {
414
- throw new Error(`Could not resolve product ID from stock entry ${stockId}`);
427
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'openStockEntry');
415
428
  }
416
429
  if (stockEntryResponse.product_id !== productId) {
417
- throw new Error(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`);
430
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'openStockEntry');
418
431
  }
419
432
  const body = {
420
433
  amount,
421
434
  stock_entry_id: stockEntryResponse.stock_id,
422
- location_id: stockEntryResponse.location_id
435
+ location_id: stockEntryResponse.location_id,
423
436
  };
424
437
  if (note)
425
438
  body.note = note;
@@ -26,7 +26,7 @@ export const inventoryModule = {
26
26
  // Granular Stock Entry Operations
27
27
  inventory_stock_entry_consume: handlers.consumeStockEntry,
28
28
  inventory_stock_entry_transfer: handlers.transferStockEntry,
29
- inventory_stock_entry_open: handlers.openStockEntry
30
- }
29
+ inventory_stock_entry_open: handlers.openStockEntry,
30
+ },
31
31
  };
32
32
  export * from './definitions.js';
@@ -23,9 +23,9 @@ export class ModuleLoader {
23
23
  try {
24
24
  const toolsDir = __dirname;
25
25
  this.discoveredFolders = readdirSync(toolsDir, { withFileTypes: true })
26
- .filter(dirent => dirent.isDirectory())
27
- .filter(dirent => !dirent.name.startsWith('.') && dirent.name !== 'node_modules')
28
- .map(dirent => dirent.name);
26
+ .filter((dirent) => dirent.isDirectory())
27
+ .filter((dirent) => !dirent.name.startsWith('.') && dirent.name !== 'node_modules')
28
+ .map((dirent) => dirent.name);
29
29
  logger.module(`Discovered ${this.discoveredFolders.length} tool folders`);
30
30
  }
31
31
  catch (error) {
@@ -41,7 +41,7 @@ export class ModuleLoader {
41
41
  const folders = this.discoverFolders();
42
42
  const toolModules = [];
43
43
  // Load modules in parallel for better performance
44
- const loadPromises = folders.map(folder => this.loadModule(folder));
44
+ const loadPromises = folders.map((folder) => this.loadModule(folder));
45
45
  const results = await Promise.allSettled(loadPromises);
46
46
  results.forEach((result, index) => {
47
47
  const folder = folders[index];
@@ -51,7 +51,7 @@ export class ModuleLoader {
51
51
  }
52
52
  else if (result.status === 'rejected') {
53
53
  logger.debug(`Failed to load module ${folder}`, 'MODULE', {
54
- error: result.reason
54
+ error: result.reason,
55
55
  });
56
56
  }
57
57
  });
@@ -97,11 +97,14 @@ export class ModuleLoader {
97
97
  * Check if an export looks like a ToolModule
98
98
  */
99
99
  static isToolModule(obj) {
100
- return obj &&
101
- typeof obj === 'object' &&
102
- Array.isArray(obj.definitions) &&
103
- typeof obj.handlers === 'object' &&
104
- obj.definitions.length > 0;
100
+ if (obj === null || typeof obj !== 'object') {
101
+ return false;
102
+ }
103
+ const m = obj;
104
+ return (Array.isArray(m.definitions) &&
105
+ m.definitions.length > 0 &&
106
+ typeof m.handlers === 'object' &&
107
+ m.handlers !== null);
105
108
  }
106
109
  /**
107
110
  * Get module from cache
@@ -125,7 +128,7 @@ export class ModuleLoader {
125
128
  return {
126
129
  total: this.moduleCache.size,
127
130
  loaded,
128
- errors
131
+ errors,
129
132
  };
130
133
  }
131
134
  }
@@ -146,6 +149,6 @@ export async function createToolRegistry() {
146
149
  getDefinitions: () => definitions,
147
150
  getHandler: (name) => handlers[name],
148
151
  getValidator: (name) => validators[name],
149
- getToolNames: () => definitions.map(def => def.name)
152
+ getToolNames: () => definitions.map((def) => def.name),
150
153
  };
151
154
  }