UNPKG

mcp-grocy

Version:

Model Context Protocol (MCP) server for Grocy integration

472 lines (471 loc) 22.1 kB
/** * Simplified recipe tool handlers * Demonstrates the new simplified pattern */ import { BaseToolHandler } from '../base.js'; import { InventoryToolHandlers } from '../inventory/handlers.js'; export class RecipeToolHandlers extends BaseToolHandler { inventoryHandlers = new InventoryToolHandlers(); /** * Helper method to get meal plan for multiple days */ async getMealPlanForDays(dates) { const allResults = []; for (const date of dates) { const dayString = date.toISOString().split('T')[0]; const dayResult = await this.apiCall(`/objects/meal_plan`, 'GET', undefined, { queryParams: { 'query[]': `day=${dayString}`, order: 'day' } }); if (Array.isArray(dayResult)) { allResults.push(...dayResult); } } return allResults; } /** * Get recipes with specified fields */ getRecipes = async (args) => { return this.executeToolHandler(async () => { const { fields } = args || {}; // Validate required parameters this.validateRequired({ fields }, ['fields']); const fieldList = this.parseArrayParam(fields, 'fields'); // Fetch recipes const recipes = await this.apiCall('/objects/recipes', 'GET', undefined, { queryParams: { 'query[]': 'type=normal' } }); if (!Array.isArray(recipes)) { return this.createSuccess([]); } // Filter to requested fields const filteredRecipes = this.filterFields(recipes, fieldList); return this.createSuccess(filteredRecipes); }); }; /** * Get recipe by ID */ getRecipeById = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const recipe = await this.apiCall(`/objects/recipes/${id}`); return this.createSuccess(recipe); }); }; /** * Get recipe fulfillment information */ getRecipeFulfillment = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const fulfillment = await this.apiCall(`/recipes/${id}/fulfillment`); return this.createSuccess(fulfillment); }); }; /** * Add recipe to meal plan */ addRecipeToMealPlan = async (args) => { return this.executeToolHandler(async () => { const { recipeId, day, mealType } = args || {}; this.validateRequired({ recipeId, day }, ['recipeId', 'day']); const id = this.parseNumberParam(recipeId, 'recipeId'); const mealPlanData = { day, type: mealType || 'lunch', recipe_id: id }; const result = await this.apiCall('/objects/meal_plan', 'POST', mealPlanData); return this.createSuccess(result, 'Recipe added to meal plan successfully'); }); }; /** * Cook recipe - consume ingredients from stock */ cookRecipe = async (args) => { return this.executeToolHandler(async () => { const { recipeId, servings } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const servingCount = this.parseNumberParam(servings, 'servings', false) || 1; // Get recipe details first const recipe = await this.apiCall(`/objects/recipes/${id}`); // Cook the recipe const cookData = { recipe_id: id, servings: servingCount }; const result = await this.apiCall('/recipes/cook', 'POST', cookData); return this.createSuccess({ recipe: recipe.name, servings: servingCount, result }, `Recipe "${recipe.name}" cooked successfully`); }); }; /** * Get recipe nutrition information */ getRecipeNutrition = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const nutrition = await this.apiCall(`/recipes/${id}/nutrition`); return this.createSuccess(nutrition); }); }; /** * Search recipes by name or ingredients */ searchRecipes = async (args) => { return this.executeToolHandler(async () => { const { query, fields } = args || {}; this.validateRequired({ query }, ['query']); const fieldList = this.parseArrayParam(fields || ['id', 'name'], 'fields'); // Get all recipes and filter locally // Note: This could be optimized with server-side search if Grocy supports it const recipes = await this.apiCall('/objects/recipes', 'GET', undefined, { queryParams: { 'query[]': 'type=normal' } }); if (!Array.isArray(recipes)) { return this.createSuccess([]); } const searchTerm = query.toLowerCase(); const filtered = recipes.filter((recipe) => recipe.name?.toLowerCase().includes(searchTerm) || recipe.description?.toLowerCase().includes(searchTerm)); const result = this.filterFields(filtered, fieldList); return this.createSuccess(result); }); }; // ==================== MEAL PLANNING METHODS ==================== /** * Get meal plan data with context */ getMealPlan = async (args) => { return this.executeToolHandler(async () => { const { date, weekly } = args || {}; // Date parameter is mandatory this.validateRequired({ date }, ['date']); const targetDate = new Date(date); if (isNaN(targetDate.getTime())) { throw new Error('Invalid date format. Use YYYY-MM-DD.'); } const datesToQuery = []; if (weekly) { // For weekly view, get the start of the week (Monday) const dayOfWeek = targetDate.getDay(); const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const startDate = new Date(targetDate); startDate.setDate(targetDate.getDate() + mondayOffset); // Get all 7 days of the week with ±1 day buffer for timezone issues for (let i = -1; i <= 7; i++) { const day = new Date(startDate); day.setDate(startDate.getDate() + i); datesToQuery.push(day); } } else { // Get the specific date with ±1 day buffer for timezone issues const dayBefore = new Date(targetDate); dayBefore.setDate(targetDate.getDate() - 1); const dayAfter = new Date(targetDate); dayAfter.setDate(targetDate.getDate() + 1); datesToQuery.push(dayBefore, targetDate, dayAfter); } const result = await this.getMealPlanForDays(datesToQuery); if (result.length === 0) { return this.createSuccess({ message: weekly ? 'No meals planned for the requested week' : 'No meals planned for the requested date', meal_plan_by_date: {} }, 'Meal plan retrieved successfully'); } // Extract unique recipe IDs const recipeIds = [...new Set(result.map(entry => entry.recipe_id).filter(id => id))]; // Fetch recipe details and all sections in parallel const [recipeDetails, allSections] = await Promise.all([ Promise.all(recipeIds.map(recipeId => this.apiCall(`/objects/recipes/${recipeId}`))), this.apiCall('/objects/meal_plan_sections') ]); // Simplify meal plan entries - don't merge recipe/section details const simplifiedMealPlanByDate = {}; result.forEach(entry => { const entryDate = entry.day; if (!simplifiedMealPlanByDate[entryDate]) { simplifiedMealPlanByDate[entryDate] = []; } simplifiedMealPlanByDate[entryDate].push({ id: entry.id, day: entry.day, section_id: entry.section_id, recipe_id: entry.recipe_id, recipe_servings: entry.recipe_servings, note: entry.note, done: entry.done }); }); return this.createSuccess({ meal_plan_by_date: simplifiedMealPlanByDate, recipes: recipeDetails.map((recipe) => ({ id: recipe.id, name: recipe.name, product_id: recipe.product_id, })), sections: Array.isArray(allSections) ? allSections.map((section) => ({ id: section.id, name: section.name, time_info: section.time_info })) : [] }, 'Meal plan retrieved successfully'); }); }; /** * Get meal plan sections */ getMealPlanSections = async () => { return this.executeToolHandler(async () => { const result = await this.apiCall('/objects/meal_plan_sections'); return this.createSuccess(result, 'Meal plan sections retrieved successfully'); }); }; /** * Delete recipe from meal plan */ deleteRecipeFromMealPlan = async (args) => { return this.executeToolHandler(async () => { const { mealPlanEntryId } = args || {}; this.validateRequired({ mealPlanEntryId }, ['mealPlanEntryId']); const result = await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`, 'DELETE'); return this.createSuccess(result, 'Recipe deleted from meal plan successfully'); }); }; // ==================== RECIPE CREATION ==================== /** * Create a new recipe */ printRecipeLabel = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const result = await this.apiCall(`/recipes/${recipeId}/printlabel`); return this.createSuccess(result, 'Recipe label printed successfully'); }); }; createRecipe = async (args) => { return this.executeToolHandler(async () => { const { name, description, baseServings, instructions } = args || {}; this.validateRequired({ name }, ['name']); const recipeData = { name, description: description || '', base_servings: baseServings || 1, type: 'normal', instructions: instructions || '' }; const result = await this.apiCall('/objects/recipes', 'POST', recipeData); return this.createSuccess(result, `Recipe "${name}" created successfully`); }); }; // ==================== RECIPE FULFILLMENT ==================== /** * Get fulfillment status for all recipes */ getAllRecipeFulfillment = async () => { return this.executeToolHandler(async () => { const fulfillment = await this.apiCall('/recipes/fulfillment'); return this.createSuccess(fulfillment); }); }; // ==================== RECIPE CONSUMPTION ==================== /** * Consume/cook a recipe (simple version) */ consumeRecipe = async (args) => { return this.executeToolHandler(async () => { const { recipeId, servings } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const servingCount = this.parseNumberParam(servings, 'servings', false) || 1; const consumeData = { recipe_id: id, servings: servingCount }; const result = await this.apiCall('/recipes/consume', 'POST', consumeData); return this.createSuccess(result, `Recipe consumed (${servingCount} servings)`); }); }; // ==================== RECIPE SHOPPING INTEGRATION ==================== /** * Add all products from a recipe to shopping list */ addAllProductsToShopping = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const result = await this.apiCall(`/recipes/${id}/add-all-ingredients-to-shopping-list`, 'POST'); return this.createSuccess(result, 'All recipe products added to shopping list'); }); }; /** * Add missing products from a recipe to shopping list */ addMissingProductsToShopping = async (args) => { return this.executeToolHandler(async () => { const { recipeId } = args || {}; this.validateRequired({ recipeId }, ['recipeId']); const id = this.parseNumberParam(recipeId, 'recipeId'); const result = await this.apiCall(`/recipes/${id}/add-not-fulfilled-products-to-shopping-list`, 'POST'); return this.createSuccess(result, 'Missing recipe products added to shopping list'); }); }; // ==================== COOKING METHODS ==================== /** * Mark recipe from meal plan entry as cooked - modernized version */ cookedSomething = async (args) => { return this.executeToolHandler(async () => { const { mealPlanEntryId, recipeId, stockAmounts } = args || {}; // Get configuration from unified config const { config } = await import('../../config/index.js'); const { toolSubConfigs } = config.parseToolConfiguration(); const subConfigs = toolSubConfigs?.get('complete'); const allowMealPlanEntryAlreadyDone = subConfigs?.get('allow_meal_plan_entry_already_done') ?? false; const printLabels = subConfigs?.get('print_labels') ?? true; const allowNoMealPlan = subConfigs?.get('allow_no_meal_plan') ?? false; // Validate parameters based on configuration if (!allowNoMealPlan && !mealPlanEntryId) { throw new Error('mealPlanEntryId is required when allow_no_meal_plan is false.'); } if (allowNoMealPlan && !recipeId) { throw new Error('recipeId is required when allow_no_meal_plan is true.'); } if (allowNoMealPlan && mealPlanEntryId) { throw new Error('mealPlanEntryId should not be provided when allow_no_meal_plan is true. Use recipeId instead.'); } this.validateRequired({ stockAmounts }, ['stockAmounts']); if (!Array.isArray(stockAmounts) || stockAmounts.length === 0) { throw new Error('stockAmounts must be a non-empty array of serving amounts.'); } // Validate all stock amounts are positive numbers for (let i = 0; i < stockAmounts.length; i++) { const amount = stockAmounts[i]; if (typeof amount !== 'number' || amount <= 0) { throw new Error(`stockAmounts[${i}] must be a positive number, got: ${amount}`); } } const completedSteps = []; let actualRecipeId; let totalServings; let mealPlanDate; let mealplanShadow; if (allowNoMealPlan) { // Direct recipe mode - no meal plan entry involved actualRecipeId = recipeId; totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0); mealPlanDate = new Date().toISOString().split('T')[0]; mealplanShadow = `${mealPlanDate}#direct-recipe-${actualRecipeId}`; completedSteps.push('Using direct recipe mode (no meal plan entry)'); } else { // Meal plan mode - traditional workflow const mealPlanEntry = await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`); if (!mealPlanEntry) { throw new Error(`Meal plan entry ${mealPlanEntryId} not found.`); } if (mealPlanEntry.done == 1 && !allowMealPlanEntryAlreadyDone) { throw new Error(`Meal plan entry ${mealPlanEntryId} is already marked as done. Cannot mark as cooked again.`); } actualRecipeId = mealPlanEntry.recipe_id; totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0); mealPlanDate = mealPlanEntry.day || new Date().toISOString().split('T')[0]; mealplanShadow = `${mealPlanDate}#${mealPlanEntryId}`; // Mark the meal plan entry as done and update recipe_servings await this.apiCall(`/objects/meal_plan/${mealPlanEntryId}`, 'PUT', { done: 1, recipe_servings: totalServings }); completedSteps.push('Meal plan entry marked as done'); } // Consume recipe ingredients if (allowNoMealPlan) { // Direct consumption using the recipe ID await this.apiCall(`/recipes/${actualRecipeId}/consume`, 'POST'); completedSteps.push('Recipe consumed directly'); } else { // Query for the mealplan shadow recipe by name const shadowRecipes = await this.apiCall('/objects/recipes', 'GET', undefined, { queryParams: { 'query[]': `name=${mealplanShadow}` } }); if (!Array.isArray(shadowRecipes) || shadowRecipes.length === 0) { throw new Error(`Mealplan shadow recipe '${mealplanShadow}' not found. Cannot consume ingredients.`); } const shadowRecipeId = shadowRecipes[0].id; // Consume ingredients using the shadow recipe ID await this.apiCall(`/recipes/${shadowRecipeId}/consume`, 'POST'); completedSteps.push('Recipe consumed via meal plan entry'); } // Handle stock entry splitting and label printing let stockEntries = { splitEntries: [], labelsPrinted: 0 }; const recipe = await this.apiCall(`/objects/recipes/${actualRecipeId}`); if (recipe && recipe.product_id) { // Get product details and most recent stock entry const [product, entries] = await Promise.all([ this.apiCall(`/objects/products/${recipe.product_id}`), this.apiCall(`/stock/products/${recipe.product_id}/entries`, 'GET', undefined, { queryParams: { order: 'row_created_timestamp:desc', limit: '1' } }) ]); // Get quantity unit info let quantityUnit = null; if (product.qu_id_stock) { try { quantityUnit = await this.apiCall(`/objects/quantity_units/${product.qu_id_stock}`); } catch (error) { console.warn('Failed to fetch quantity unit:', error); } } // Helper function to get correct unit form const getUnitForm = (amount) => { if (!quantityUnit || !quantityUnit.name) return ''; if (amount === 1) return quantityUnit.name; return quantityUnit.name_plural || quantityUnit.name; }; if (Array.isArray(entries) && entries.length > 0) { const originalEntry = entries[0]; // Use the real stock splitting functionality stockEntries.splitEntries = await this.inventoryHandlers.splitStockEntry(originalEntry, stockAmounts, getUnitForm); // Print labels for all entries (if enabled) if (printLabels) { for (const entry of stockEntries.splitEntries) { try { await this.apiCall(`/stock/entry/${originalEntry.id}/printlabel`); stockEntries.labelsPrinted++; } catch (error) { console.error(`Failed to print label for stock entry ${entry.stockId}:`, error); } } } } } return this.createSuccess({ message: `Recipe ${actualRecipeId} cooked (${totalServings} servings consumed, ${stockEntries.splitEntries.length} stock entries created, ${stockEntries.labelsPrinted} labels printed)`, stockEntries, completedSteps }); }); }; }