mcp-grocy 2.7.1 → 2.8.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 (41) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +16 -6
  3. package/build/api/client.js +119 -0
  4. package/build/config/index.js +283 -0
  5. package/build/main.js +44 -0
  6. package/build/resources/CHANGELOG.md +22 -3
  7. package/build/resources/README.md +16 -6
  8. package/build/resources/api-reference.md +2 -1
  9. package/build/resources/examples.md +13 -1
  10. package/build/resources/response-format.md +1 -3
  11. package/build/server/http-server.js +398 -0
  12. package/build/server/mcp-server.js +230 -0
  13. package/build/server/resources.js +73 -0
  14. package/build/server/tool-input-zod.js +118 -0
  15. package/build/tools/base.js +190 -0
  16. package/build/tools/base.test.js +64 -0
  17. package/build/tools/household/definitions.js +161 -0
  18. package/build/tools/household/handlers.js +109 -0
  19. package/build/tools/household/index.js +25 -0
  20. package/build/tools/index.js +2 -0
  21. package/build/tools/inventory/definitions.js +416 -0
  22. package/build/tools/inventory/handlers.js +443 -0
  23. package/build/tools/inventory/index.js +32 -0
  24. package/build/tools/module-loader.js +154 -0
  25. package/build/tools/recipes/definitions.js +300 -0
  26. package/build/tools/recipes/handlers.js +533 -0
  27. package/build/tools/recipes/index.js +34 -0
  28. package/build/tools/recipes/validations.js +29 -0
  29. package/build/tools/shopping/definitions.js +147 -0
  30. package/build/tools/shopping/handlers.js +198 -0
  31. package/build/tools/shopping/index.js +17 -0
  32. package/build/tools/system/definitions.js +89 -0
  33. package/build/tools/system/handlers.js +156 -0
  34. package/build/tools/system/index.js +16 -0
  35. package/build/tools/types.js +1 -0
  36. package/build/tools/validation-helpers.js +39 -0
  37. package/build/types/index.js +64 -0
  38. package/build/utils/errors.js +143 -0
  39. package/build/utils/logger.js +142 -0
  40. package/build/version.js +1 -1
  41. package/package.json +27 -25
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Simplified recipe tool handlers
3
+ * Demonstrates the new simplified pattern
4
+ */
5
+ import { BaseToolHandler } from '../base.js';
6
+ import { InventoryToolHandlers } from '../inventory/handlers.js';
7
+ import { ValidationError } from '../../utils/errors.js';
8
+ const RECIPES_COOKING_COMPLETE_TOOL = 'recipes_cooking_complete';
9
+ export class RecipeToolHandlers extends BaseToolHandler {
10
+ inventoryHandlers = new InventoryToolHandlers();
11
+ /**
12
+ * Helper method to get meal plan for multiple days
13
+ */
14
+ async getMealPlanForDays(dates) {
15
+ const allResults = [];
16
+ for (const date of dates) {
17
+ const dayString = date.toISOString().split('T')[0];
18
+ const dayResult = await this.apiCall(`/objects/meal_plan`, 'GET', undefined, {
19
+ queryParams: {
20
+ 'query[]': `day=${dayString}`,
21
+ order: 'day',
22
+ },
23
+ });
24
+ if (Array.isArray(dayResult)) {
25
+ allResults.push(...dayResult);
26
+ }
27
+ }
28
+ return allResults;
29
+ }
30
+ /**
31
+ * Validate a YYYY-MM-DD day parameter. Grocy stores any string in this column, so both the
32
+ * format and the calendar date are checked before writing.
33
+ */
34
+ validateDay(day, context) {
35
+ const parsed = typeof day === 'string' ? new Date(`${day}T00:00:00Z`) : new Date(NaN);
36
+ const valid = typeof day === 'string' &&
37
+ /^\d{4}-\d{2}-\d{2}$/.test(day) &&
38
+ !Number.isNaN(parsed.getTime()) &&
39
+ parsed.toISOString().slice(0, 10) === day;
40
+ if (!valid) {
41
+ throw new ValidationError('day must be a valid calendar date in YYYY-MM-DD format (e.g., "2024-12-25").', context);
42
+ }
43
+ }
44
+ /**
45
+ * Create a meal plan entry with the same payload shape as the Grocy UI.
46
+ * `fields` carries the type-specific columns of the entry (type, recipe_id, recipe_servings, ...).
47
+ */
48
+ async createMealPlanEntry(day, sectionId, fields, context) {
49
+ this.validateDay(day, context);
50
+ const section = this.parseNumberParam(sectionId, 'sectionId');
51
+ // Grocy has no section 0 and no foreign-key check: -1 is its built-in "no section",
52
+ // real section ids start at 1.
53
+ if (!Number.isInteger(section) || (section !== -1 && section < 1)) {
54
+ throw new ValidationError('sectionId must be a section id from recipes_mealplan_get_sections, or -1 for no section', context);
55
+ }
56
+ return this.apiCall('/objects/meal_plan', 'POST', { day, section_id: section, ...fields });
57
+ }
58
+ /**
59
+ * Get recipes with specified fields
60
+ */
61
+ getRecipes = async (args) => {
62
+ return this.executeToolHandler(async () => {
63
+ const { fields } = args || {};
64
+ // Validate required parameters
65
+ this.validateRequired({ fields }, ['fields']);
66
+ const fieldList = this.parseArrayParam(fields, 'fields');
67
+ // Fetch recipes
68
+ const recipes = await this.apiCall('/objects/recipes', 'GET', undefined, {
69
+ queryParams: { 'query[]': 'type=normal' },
70
+ });
71
+ if (!Array.isArray(recipes)) {
72
+ return this.createSuccess([]);
73
+ }
74
+ // Filter to requested fields
75
+ const filteredRecipes = this.filterFields(recipes, fieldList);
76
+ return this.createSuccess(filteredRecipes);
77
+ });
78
+ };
79
+ /**
80
+ * Get recipe by ID
81
+ */
82
+ getRecipeById = async (args) => {
83
+ return this.executeToolHandler(async () => {
84
+ const { recipeId } = args || {};
85
+ this.validateRequired({ recipeId }, ['recipeId']);
86
+ const id = this.parseNumberParam(recipeId, 'recipeId');
87
+ const recipe = await this.apiCall(`/objects/recipes/${id}`);
88
+ return this.createSuccess(recipe);
89
+ });
90
+ };
91
+ /**
92
+ * Get recipe fulfillment information
93
+ */
94
+ getRecipeFulfillment = async (args) => {
95
+ return this.executeToolHandler(async () => {
96
+ const { recipeId } = args || {};
97
+ this.validateRequired({ recipeId }, ['recipeId']);
98
+ const id = this.parseNumberParam(recipeId, 'recipeId');
99
+ const fulfillment = await this.apiCall(`/recipes/${id}/fulfillment`);
100
+ return this.createSuccess(fulfillment);
101
+ });
102
+ };
103
+ /**
104
+ * Add recipe to meal plan
105
+ */
106
+ addRecipeToMealPlan = async (args) => {
107
+ return this.executeToolHandler(async () => {
108
+ const { recipeId, day, servings, sectionId } = args || {};
109
+ this.validateRequired({ recipeId, day, servings, sectionId }, [
110
+ 'recipeId',
111
+ 'day',
112
+ 'servings',
113
+ 'sectionId',
114
+ ]);
115
+ const id = this.parseNumberParam(recipeId, 'recipeId');
116
+ const recipeServings = this.parseNumberParam(servings, 'servings');
117
+ if (recipeServings <= 0) {
118
+ throw new ValidationError('servings must be a positive number', 'recipes_mealplan_add_recipe');
119
+ }
120
+ const result = await this.createMealPlanEntry(day, sectionId, { type: 'recipe', recipe_id: id, recipe_servings: recipeServings }, 'recipes_mealplan_add_recipe');
121
+ return this.createSuccess(result, 'Recipe added to meal plan successfully');
122
+ });
123
+ };
124
+ /**
125
+ * Add free-text note to meal plan
126
+ */
127
+ addNoteToMealPlan = async (args) => {
128
+ return this.executeToolHandler(async () => {
129
+ const { day, note, sectionId } = args || {};
130
+ this.validateRequired({ day, note, sectionId }, ['day', 'note', 'sectionId']);
131
+ if (typeof note !== 'string' || note.trim() === '') {
132
+ throw new ValidationError('note must be non-empty text', 'recipes_mealplan_add_note');
133
+ }
134
+ const result = await this.createMealPlanEntry(day, sectionId, { type: 'note', note }, 'recipes_mealplan_add_note');
135
+ return this.createSuccess(result, 'Note added to meal plan successfully');
136
+ });
137
+ };
138
+ /**
139
+ * Cook recipe - consume ingredients from stock
140
+ */
141
+ cookRecipe = async (args) => {
142
+ return this.executeToolHandler(async () => {
143
+ const { recipeId, servings } = args || {};
144
+ this.validateRequired({ recipeId }, ['recipeId']);
145
+ const id = this.parseNumberParam(recipeId, 'recipeId');
146
+ const servingCount = this.parseNumberParam(servings, 'servings', false) || 1;
147
+ // Get recipe details first
148
+ const recipe = await this.apiCall(`/objects/recipes/${id}`);
149
+ // Cook the recipe
150
+ const cookData = {
151
+ recipe_id: id,
152
+ servings: servingCount,
153
+ };
154
+ const result = await this.apiCall('/recipes/cook', 'POST', cookData);
155
+ return this.createSuccess({
156
+ recipe: recipe.name,
157
+ servings: servingCount,
158
+ result,
159
+ }, `Recipe "${recipe.name}" cooked successfully`);
160
+ });
161
+ };
162
+ /**
163
+ * Get recipe nutrition information
164
+ */
165
+ getRecipeNutrition = async (args) => {
166
+ return this.executeToolHandler(async () => {
167
+ const { recipeId } = args || {};
168
+ this.validateRequired({ recipeId }, ['recipeId']);
169
+ const id = this.parseNumberParam(recipeId, 'recipeId');
170
+ const nutrition = await this.apiCall(`/recipes/${id}/nutrition`);
171
+ return this.createSuccess(nutrition);
172
+ });
173
+ };
174
+ /**
175
+ * Search recipes by name or ingredients
176
+ */
177
+ searchRecipes = async (args) => {
178
+ return this.executeToolHandler(async () => {
179
+ const { query, fields } = args || {};
180
+ this.validateRequired({ query }, ['query']);
181
+ const fieldList = this.parseArrayParam(fields || ['id', 'name'], 'fields');
182
+ // Get all recipes and filter locally
183
+ // Note: This could be optimized with server-side search if Grocy supports it
184
+ const recipes = await this.apiCall('/objects/recipes', 'GET', undefined, {
185
+ queryParams: { 'query[]': 'type=normal' },
186
+ });
187
+ if (!Array.isArray(recipes)) {
188
+ return this.createSuccess([]);
189
+ }
190
+ const searchTerm = query.toLowerCase();
191
+ const filtered = recipes.filter((recipe) => recipe.name?.toLowerCase().includes(searchTerm) ||
192
+ recipe.description?.toLowerCase().includes(searchTerm));
193
+ const result = this.filterFields(filtered, fieldList);
194
+ return this.createSuccess(result);
195
+ });
196
+ };
197
+ // ==================== MEAL PLANNING METHODS ====================
198
+ /**
199
+ * Get meal plan data with context
200
+ */
201
+ getMealPlan = async (args) => {
202
+ return this.executeToolHandler(async () => {
203
+ const { date, weekly } = args || {};
204
+ // Date parameter is mandatory
205
+ this.validateRequired({ date }, ['date']);
206
+ const targetDate = new Date(date);
207
+ if (isNaN(targetDate.getTime())) {
208
+ throw new ValidationError('Invalid date format. Use YYYY-MM-DD.', 'getMealPlan');
209
+ }
210
+ const datesToQuery = [];
211
+ if (weekly) {
212
+ // For weekly view, get the start of the week (Monday)
213
+ const dayOfWeek = targetDate.getDay();
214
+ const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
215
+ const startDate = new Date(targetDate);
216
+ startDate.setDate(targetDate.getDate() + mondayOffset);
217
+ // Get all 7 days of the week with ±1 day buffer for timezone issues
218
+ for (let i = -1; i <= 7; i++) {
219
+ const day = new Date(startDate);
220
+ day.setDate(startDate.getDate() + i);
221
+ datesToQuery.push(day);
222
+ }
223
+ }
224
+ else {
225
+ // Get the specific date with ±1 day buffer for timezone issues
226
+ const dayBefore = new Date(targetDate);
227
+ dayBefore.setDate(targetDate.getDate() - 1);
228
+ const dayAfter = new Date(targetDate);
229
+ dayAfter.setDate(targetDate.getDate() + 1);
230
+ datesToQuery.push(dayBefore, targetDate, dayAfter);
231
+ }
232
+ const result = await this.getMealPlanForDays(datesToQuery);
233
+ if (result.length === 0) {
234
+ return this.createSuccess({
235
+ message: weekly
236
+ ? 'No meals planned for the requested week'
237
+ : 'No meals planned for the requested date',
238
+ meal_plan_by_date: {},
239
+ }, 'Meal plan retrieved successfully');
240
+ }
241
+ // Extract unique recipe IDs
242
+ const recipeIds = [...new Set(result.map((entry) => entry.recipe_id).filter((id) => id))];
243
+ // Fetch recipe details and all sections in parallel
244
+ const [recipeDetails, allSections] = await Promise.all([
245
+ Promise.all(recipeIds.map((recipeId) => this.apiCall(`/objects/recipes/${recipeId}`))),
246
+ this.apiCall('/objects/meal_plan_sections'),
247
+ ]);
248
+ // Simplify meal plan entries - don't merge recipe/section details
249
+ const simplifiedMealPlanByDate = {};
250
+ result.forEach((entry) => {
251
+ const entryDate = entry.day;
252
+ if (!simplifiedMealPlanByDate[entryDate]) {
253
+ simplifiedMealPlanByDate[entryDate] = [];
254
+ }
255
+ simplifiedMealPlanByDate[entryDate].push({
256
+ id: entry.id,
257
+ day: entry.day,
258
+ type: entry.type,
259
+ section_id: entry.section_id,
260
+ recipe_id: entry.recipe_id,
261
+ recipe_servings: entry.recipe_servings,
262
+ note: entry.note,
263
+ done: entry.done,
264
+ });
265
+ });
266
+ return this.createSuccess({
267
+ meal_plan_by_date: simplifiedMealPlanByDate,
268
+ recipes: recipeDetails.map((recipe) => ({
269
+ id: recipe.id,
270
+ name: recipe.name,
271
+ product_id: recipe.product_id,
272
+ })),
273
+ sections: Array.isArray(allSections)
274
+ ? allSections.map((section) => ({
275
+ id: section.id,
276
+ name: section.name,
277
+ time_info: section.time_info,
278
+ }))
279
+ : [],
280
+ }, 'Meal plan retrieved successfully');
281
+ });
282
+ };
283
+ /**
284
+ * Get meal plan sections
285
+ */
286
+ getMealPlanSections = async () => {
287
+ return this.executeToolHandler(async () => {
288
+ const result = await this.apiCall('/objects/meal_plan_sections');
289
+ return this.createSuccess(result, 'Meal plan sections retrieved successfully');
290
+ });
291
+ };
292
+ /**
293
+ * Delete a meal plan entry (any type)
294
+ */
295
+ deleteMealPlanEntry = async (args) => {
296
+ return this.executeToolHandler(async () => {
297
+ const { mealPlanEntryId } = args || {};
298
+ this.validateRequired({ mealPlanEntryId }, ['mealPlanEntryId']);
299
+ const result = await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`, 'DELETE');
300
+ return this.createSuccess(result, 'Meal plan entry deleted successfully');
301
+ });
302
+ };
303
+ // ==================== RECIPE CREATION ====================
304
+ /**
305
+ * Create a new recipe
306
+ */
307
+ printRecipeLabel = async (args) => {
308
+ return this.executeToolHandler(async () => {
309
+ const { recipeId } = args || {};
310
+ this.validateRequired({ recipeId }, ['recipeId']);
311
+ const result = await this.apiCall(`/recipes/${recipeId}/printlabel`);
312
+ return this.createSuccess(result, 'Recipe label printed successfully');
313
+ });
314
+ };
315
+ createRecipe = async (args) => {
316
+ return this.executeToolHandler(async () => {
317
+ const { name, description, baseServings, instructions } = args || {};
318
+ this.validateRequired({ name }, ['name']);
319
+ const recipeData = {
320
+ name,
321
+ description: description || '',
322
+ base_servings: baseServings || 1,
323
+ type: 'normal',
324
+ instructions: instructions || '',
325
+ };
326
+ const result = await this.apiCall('/objects/recipes', 'POST', recipeData);
327
+ return this.createSuccess(result, `Recipe "${name}" created successfully`);
328
+ });
329
+ };
330
+ // ==================== RECIPE FULFILLMENT ====================
331
+ /**
332
+ * Get fulfillment status for all recipes
333
+ */
334
+ getAllRecipeFulfillment = async () => {
335
+ return this.executeToolHandler(async () => {
336
+ const fulfillment = await this.apiCall('/recipes/fulfillment');
337
+ return this.createSuccess(fulfillment);
338
+ });
339
+ };
340
+ // ==================== RECIPE CONSUMPTION ====================
341
+ /**
342
+ * Consume/cook a recipe (simple version)
343
+ */
344
+ consumeRecipe = async (args) => {
345
+ return this.executeToolHandler(async () => {
346
+ const { recipeId, servings } = args || {};
347
+ this.validateRequired({ recipeId }, ['recipeId']);
348
+ const id = this.parseNumberParam(recipeId, 'recipeId');
349
+ const servingCount = this.parseNumberParam(servings, 'servings', false) || 1;
350
+ const consumeData = {
351
+ recipe_id: id,
352
+ servings: servingCount,
353
+ };
354
+ const result = await this.apiCall('/recipes/consume', 'POST', consumeData);
355
+ return this.createSuccess(result, `Recipe consumed (${servingCount} servings)`);
356
+ });
357
+ };
358
+ // ==================== RECIPE SHOPPING INTEGRATION ====================
359
+ /**
360
+ * Add all products from a recipe to shopping list
361
+ */
362
+ addAllProductsToShopping = async (args) => {
363
+ return this.executeToolHandler(async () => {
364
+ const { recipeId } = args || {};
365
+ this.validateRequired({ recipeId }, ['recipeId']);
366
+ const id = this.parseNumberParam(recipeId, 'recipeId');
367
+ const result = await this.apiCall(`/recipes/${id}/add-all-ingredients-to-shopping-list`, 'POST');
368
+ return this.createSuccess(result, 'All recipe products added to shopping list');
369
+ });
370
+ };
371
+ /**
372
+ * Add missing products from a recipe to shopping list
373
+ */
374
+ addMissingProductsToShopping = async (args) => {
375
+ return this.executeToolHandler(async () => {
376
+ const { recipeId } = args || {};
377
+ this.validateRequired({ recipeId }, ['recipeId']);
378
+ const id = this.parseNumberParam(recipeId, 'recipeId');
379
+ const result = await this.apiCall(`/recipes/${id}/add-not-fulfilled-products-to-shopping-list`, 'POST');
380
+ return this.createSuccess(result, 'Missing recipe products added to shopping list');
381
+ });
382
+ };
383
+ // ==================== COOKING METHODS ====================
384
+ /**
385
+ * Mark recipe from meal plan entry as cooked - modernized version
386
+ */
387
+ cookedSomething = async (args) => {
388
+ return this.executeToolHandler(async () => {
389
+ const { mealPlanEntryId, recipeId, stockAmounts } = args || {};
390
+ // Get configuration from unified config
391
+ const { config } = await import('../../config/index.js');
392
+ const { toolSubConfigs } = config.parseToolConfiguration();
393
+ const subConfigs = toolSubConfigs?.get(RECIPES_COOKING_COMPLETE_TOOL);
394
+ const allowMealPlanEntryAlreadyDone = subConfigs?.get('allow_meal_plan_entry_already_done') ?? false;
395
+ const printLabels = subConfigs?.get('print_labels') ?? true;
396
+ const allowNoMealPlan = subConfigs?.get('allow_no_meal_plan') ?? false;
397
+ // Validate parameters based on configuration
398
+ if (!allowNoMealPlan && !mealPlanEntryId) {
399
+ throw new ValidationError('mealPlanEntryId is required when allow_no_meal_plan is false.', 'recipes_cooking_complete');
400
+ }
401
+ if (allowNoMealPlan && !recipeId) {
402
+ throw new ValidationError('recipeId is required when allow_no_meal_plan is true.', 'recipes_cooking_complete');
403
+ }
404
+ if (allowNoMealPlan && mealPlanEntryId) {
405
+ throw new ValidationError('mealPlanEntryId should not be provided when allow_no_meal_plan is true. Use recipeId instead.', 'recipes_cooking_complete');
406
+ }
407
+ this.validateRequired({ stockAmounts }, ['stockAmounts']);
408
+ if (!Array.isArray(stockAmounts) || stockAmounts.length === 0) {
409
+ throw new ValidationError('stockAmounts must be a non-empty array of serving amounts.', 'recipes_cooking_complete');
410
+ }
411
+ // Validate all stock amounts are positive numbers
412
+ for (let i = 0; i < stockAmounts.length; i++) {
413
+ const amount = stockAmounts[i];
414
+ if (typeof amount !== 'number' || amount <= 0) {
415
+ throw new ValidationError(`stockAmounts[${i}] must be a positive number, got: ${amount}`, 'recipes_cooking_complete');
416
+ }
417
+ }
418
+ const completedSteps = [];
419
+ let actualRecipeId;
420
+ let totalServings;
421
+ let mealPlanDate;
422
+ let mealplanShadow;
423
+ if (allowNoMealPlan) {
424
+ // Direct recipe mode - no meal plan entry involved
425
+ actualRecipeId = recipeId;
426
+ totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0);
427
+ mealPlanDate = new Date().toISOString().split('T')[0];
428
+ mealplanShadow = `${mealPlanDate}#direct-recipe-${actualRecipeId}`;
429
+ completedSteps.push('Using direct recipe mode (no meal plan entry)');
430
+ }
431
+ else {
432
+ // Meal plan mode - traditional workflow
433
+ const mealPlanEntry = await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`);
434
+ if (!mealPlanEntry) {
435
+ throw new ValidationError(`Meal plan entry ${mealPlanEntryId} not found.`, 'recipes_cooking_complete');
436
+ }
437
+ // Only recipe-typed entries are handled here; check before any write so a note, a
438
+ // product entry or an entry with an unrecognised type is never marked as done.
439
+ if (mealPlanEntry.type !== 'recipe' || mealPlanEntry.recipe_id == null) {
440
+ const hasRecipe = mealPlanEntry.recipe_id != null;
441
+ const reason = hasRecipe
442
+ ? 'it was stored with an unrecognised type, so Grocy created no shadow recipe for it; delete it with recipes_mealplan_delete_entry and plan it again'
443
+ : 'note entries have nothing to consume and product entries are not supported by this tool';
444
+ throw new ValidationError(`Meal plan entry ${mealPlanEntryId} cannot be cooked (type '${mealPlanEntry.type ?? 'unknown'}'${hasRecipe ? '' : ', no recipe_id'}): only entries with type 'recipe' are handled; ${reason}.`, 'recipes_cooking_complete');
445
+ }
446
+ if (mealPlanEntry.done == 1 && !allowMealPlanEntryAlreadyDone) {
447
+ throw new ValidationError(`Meal plan entry ${mealPlanEntryId} is already marked as done. Cannot mark as cooked again.`, 'recipes_cooking_complete');
448
+ }
449
+ actualRecipeId = mealPlanEntry.recipe_id;
450
+ totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0);
451
+ mealPlanDate = mealPlanEntry.day || new Date().toISOString().split('T')[0];
452
+ mealplanShadow = `${mealPlanDate}#${mealPlanEntryId}`;
453
+ // Mark the meal plan entry as done and update recipe_servings
454
+ await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`, 'PUT', {
455
+ done: 1,
456
+ recipe_servings: totalServings,
457
+ });
458
+ completedSteps.push('Meal plan entry marked as done');
459
+ }
460
+ // Consume recipe ingredients
461
+ if (allowNoMealPlan) {
462
+ // Direct consumption using the recipe ID
463
+ await this.apiCall(`/recipes/${actualRecipeId}/consume`, 'POST');
464
+ completedSteps.push('Recipe consumed directly');
465
+ }
466
+ else {
467
+ // Query for the mealplan shadow recipe by name
468
+ const shadowRecipes = await this.apiCall('/objects/recipes', 'GET', undefined, {
469
+ queryParams: { 'query[]': `name=${mealplanShadow}` },
470
+ });
471
+ if (!Array.isArray(shadowRecipes) || shadowRecipes.length === 0) {
472
+ throw new ValidationError(`Mealplan shadow recipe '${mealplanShadow}' not found. Cannot consume ingredients.`, 'recipes_cooking_complete');
473
+ }
474
+ const shadowRecipeId = shadowRecipes[0].id;
475
+ // Consume ingredients using the shadow recipe ID
476
+ await this.apiCall(`/recipes/${shadowRecipeId}/consume`, 'POST');
477
+ completedSteps.push('Recipe consumed via meal plan entry');
478
+ }
479
+ // Handle stock entry splitting and label printing
480
+ const stockEntries = { splitEntries: [], labelsPrinted: 0 };
481
+ const recipe = await this.apiCall(`/objects/recipes/${actualRecipeId}`);
482
+ if (recipe && recipe.product_id) {
483
+ // Get product details and most recent stock entry
484
+ const [product, entries] = await Promise.all([
485
+ this.apiCall(`/objects/products/${recipe.product_id}`),
486
+ this.apiCall(`/stock/products/${recipe.product_id}/entries`, 'GET', undefined, {
487
+ queryParams: { order: 'row_created_timestamp:desc', limit: '1' },
488
+ }),
489
+ ]);
490
+ // Get quantity unit info
491
+ let quantityUnit = null;
492
+ if (product.qu_id_stock) {
493
+ try {
494
+ quantityUnit = await this.apiCall(`/objects/quantity_units/${product.qu_id_stock}`);
495
+ }
496
+ catch (error) {
497
+ console.warn('Failed to fetch quantity unit:', error);
498
+ }
499
+ }
500
+ // Helper function to get correct unit form
501
+ const getUnitForm = (amount) => {
502
+ if (!quantityUnit || !quantityUnit.name)
503
+ return '';
504
+ if (amount === 1)
505
+ return quantityUnit.name;
506
+ return quantityUnit.name_plural || quantityUnit.name;
507
+ };
508
+ if (Array.isArray(entries) && entries.length > 0) {
509
+ const originalEntry = entries[0];
510
+ // Use the real stock splitting functionality
511
+ stockEntries.splitEntries = await this.inventoryHandlers.splitStockEntry(originalEntry, stockAmounts, getUnitForm);
512
+ // Print labels for all entries (if enabled)
513
+ if (printLabels) {
514
+ for (const entry of stockEntries.splitEntries) {
515
+ try {
516
+ await this.apiCall(`/stock/entry/${originalEntry.id}/printlabel`);
517
+ stockEntries.labelsPrinted++;
518
+ }
519
+ catch (error) {
520
+ console.error(`Failed to print label for stock entry ${entry.stockId}:`, error);
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ return this.createSuccess({
527
+ message: `Recipe ${actualRecipeId} cooked (${totalServings} servings consumed, ${stockEntries.splitEntries.length} stock entries created, ${stockEntries.labelsPrinted} labels printed)`,
528
+ stockEntries,
529
+ completedSteps,
530
+ });
531
+ });
532
+ };
533
+ }
@@ -0,0 +1,34 @@
1
+ import { recipeToolDefinitions } from './definitions.js';
2
+ import { RecipeToolHandlers } from './handlers.js';
3
+ import { validateCompleteSubConfigs } from './validations.js';
4
+ // Use simplified handlers
5
+ const handlers = new RecipeToolHandlers();
6
+ export const recipeModule = {
7
+ definitions: recipeToolDefinitions,
8
+ handlers: {
9
+ // Recipe Management
10
+ recipes_management_get: handlers.getRecipes,
11
+ recipes_management_get_by_id: handlers.getRecipeById,
12
+ recipes_management_create: handlers.createRecipe,
13
+ recipes_management_print_label: handlers.printRecipeLabel,
14
+ // Recipe Fulfillment
15
+ recipes_fulfillment_get: handlers.getRecipeFulfillment,
16
+ recipes_fulfillment_get_all: handlers.getAllRecipeFulfillment,
17
+ // Meal Planning
18
+ recipes_mealplan_get: handlers.getMealPlan,
19
+ recipes_mealplan_get_sections: handlers.getMealPlanSections,
20
+ recipes_mealplan_add_recipe: handlers.addRecipeToMealPlan,
21
+ recipes_mealplan_add_note: handlers.addNoteToMealPlan,
22
+ recipes_mealplan_delete_entry: handlers.deleteMealPlanEntry,
23
+ // Recipe Cooking
24
+ recipes_cooking_consume: handlers.consumeRecipe,
25
+ recipes_cooking_complete: handlers.cookedSomething,
26
+ // Shopping Integration
27
+ recipes_shopping_add_all_products: handlers.addAllProductsToShopping,
28
+ recipes_shopping_add_missing_products: handlers.addMissingProductsToShopping,
29
+ },
30
+ validators: {
31
+ recipes_cooking_complete: validateCompleteSubConfigs,
32
+ },
33
+ };
34
+ export * from './definitions.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Recipe tools sub-configuration validation functions
3
+ */
4
+ import { ValidationHelpers } from '../validation-helpers.js';
5
+ import { ValidationError } from '../../utils/errors.js';
6
+ /**
7
+ * Validation function for complete tool sub-configurations
8
+ */
9
+ export const validateCompleteSubConfigs = (subConfigs) => {
10
+ const allowMealPlanEntryAlreadyDone = subConfigs.get('allow_meal_plan_entry_already_done');
11
+ const allowNoMealPlan = subConfigs.get('allow_no_meal_plan');
12
+ const printLabels = subConfigs.get('print_labels');
13
+ // Validate types
14
+ ValidationHelpers.validateBoolean(allowMealPlanEntryAlreadyDone, 'allow_meal_plan_entry_already_done');
15
+ ValidationHelpers.validateBoolean(allowNoMealPlan, 'allow_no_meal_plan');
16
+ ValidationHelpers.validateBoolean(printLabels, 'print_labels');
17
+ // Business logic validation
18
+ if (allowNoMealPlan && allowMealPlanEntryAlreadyDone) {
19
+ throw new ValidationError('allow_no_meal_plan and allow_meal_plan_entry_already_done cannot both be true - they are mutually exclusive modes', 'complete sub-config');
20
+ }
21
+ // Check for unknown options
22
+ const knownOptions = new Set([
23
+ 'allow_meal_plan_entry_already_done',
24
+ 'allow_no_meal_plan',
25
+ 'print_labels',
26
+ 'ack_token',
27
+ ]);
28
+ ValidationHelpers.validateKnownOptions(subConfigs, knownOptions, 'complete');
29
+ };