mcp-grocy 1.9.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.
- package/LICENSE +23 -0
- package/README.md +285 -0
- package/build/api/client.js +154 -0
- package/build/config/environment.js +142 -0
- package/build/main.js +29 -0
- package/build/resources/CHANGELOG.md +352 -0
- package/build/resources/DOCS.md +62 -0
- package/build/resources/README.md +285 -0
- package/build/resources/api-reference.md +92 -0
- package/build/resources/config.md +282 -0
- package/build/resources/examples.md +319 -0
- package/build/resources/installation.md +117 -0
- package/build/resources/response-format.md +165 -0
- package/build/server/http-server.js +214 -0
- package/build/server/mcp-server.js +173 -0
- package/build/server/resources.js +60 -0
- package/build/tools/base.js +56 -0
- package/build/tools/index.js +494 -0
- package/build/tools/products/definitions.js +57 -0
- package/build/tools/products/handlers.js +78 -0
- package/build/tools/products/index.js +13 -0
- package/build/tools/recipes/definitions.js +191 -0
- package/build/tools/recipes/handlers.js +258 -0
- package/build/tools/recipes/index.js +18 -0
- package/build/tools/shopping/index.js +107 -0
- package/build/tools/stock/definitions.js +234 -0
- package/build/tools/stock/handlers.js +391 -0
- package/build/tools/stock/index.js +19 -0
- package/build/tools/system/index.js +230 -0
- package/build/tools/types.js +1 -0
- package/build/version.js +4 -0
- package/package.json +87 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import config from '../../config/environment.js';
|
|
2
|
+
export const recipeToolDefinitions = [
|
|
3
|
+
{
|
|
4
|
+
name: 'get_recipes',
|
|
5
|
+
description: 'Get specific fields for all recipes from your Grocy instance. You must specify which fields to retrieve.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
fields: {
|
|
10
|
+
type: 'array',
|
|
11
|
+
items: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['id', 'name', 'description', 'base_servings', 'desired_servings', 'not_check_shoppinglist', 'type', 'picture_file_name', 'ingredients', 'instructions']
|
|
14
|
+
},
|
|
15
|
+
description: 'Array of field names to retrieve. For basic lookup use ["id", "name"]. For recipe planning use ["id", "name", "description", "base_servings"]. Available fields: id, name, description, base_servings, desired_servings, not_check_shoppinglist, type, picture_file_name, ingredients, instructions'
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
required: ['fields']
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'get_recipe_by_id',
|
|
23
|
+
description: 'Get a specific recipe by its ID from your Grocy instance.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
recipeId: {
|
|
28
|
+
type: 'number',
|
|
29
|
+
description: 'ID of the recipe to retrieve'
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
required: ['recipeId']
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'create_recipe',
|
|
37
|
+
description: 'Create a new recipe in your Grocy instance.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
name: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Name of the recipe'
|
|
44
|
+
},
|
|
45
|
+
description: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Description of the recipe'
|
|
48
|
+
},
|
|
49
|
+
servings: {
|
|
50
|
+
type: 'number',
|
|
51
|
+
description: 'Number of servings (default: 1)',
|
|
52
|
+
default: 1
|
|
53
|
+
},
|
|
54
|
+
baseServingAmount: {
|
|
55
|
+
type: 'number',
|
|
56
|
+
description: 'Base serving amount (default: 1)',
|
|
57
|
+
default: 1
|
|
58
|
+
},
|
|
59
|
+
desiredServings: {
|
|
60
|
+
type: 'number',
|
|
61
|
+
description: 'Number of desired servings (default: 1)',
|
|
62
|
+
default: 1
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
required: ['name']
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'get_recipe_fulfillment',
|
|
70
|
+
description: 'Get stock fulfillment information for a recipe. Use get_recipes first to find the recipe ID.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
recipeId: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'ID of the recipe to check fulfillment for. Use get_recipes tool to find the correct recipe ID by name.'
|
|
77
|
+
},
|
|
78
|
+
servings: {
|
|
79
|
+
type: 'number',
|
|
80
|
+
description: 'Number of servings (default: 1)',
|
|
81
|
+
default: 1
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
required: ['recipeId']
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'get_recipes_fulfillment',
|
|
89
|
+
description: 'Get fulfillment information for all recipes.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {},
|
|
93
|
+
required: []
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'consume_recipe',
|
|
98
|
+
description: 'Consume all ingredients needed for a recipe in your Grocy instance. Use get_recipes first to find the recipe ID.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
recipeId: {
|
|
103
|
+
type: 'number',
|
|
104
|
+
description: 'ID of the recipe to consume. Use get_recipes tool to find the correct recipe ID by name.'
|
|
105
|
+
},
|
|
106
|
+
servings: {
|
|
107
|
+
type: 'number',
|
|
108
|
+
description: 'Number of servings to consume (default: 1)',
|
|
109
|
+
default: 1
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
required: ['recipeId']
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'add_recipe_products_to_shopping_list',
|
|
117
|
+
description: 'Add not fulfilled products of a recipe to the shopping list.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
recipeId: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'ID of the recipe'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
required: ['recipeId']
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'add_missing_products_to_shopping_list',
|
|
131
|
+
description: 'Add all missing products for a recipe to your shopping list. Use get_recipes first to find the recipe ID.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: {
|
|
135
|
+
recipeId: {
|
|
136
|
+
type: 'number',
|
|
137
|
+
description: 'ID of the recipe to add missing products for. Use get_recipes tool to find the correct recipe ID by name.'
|
|
138
|
+
},
|
|
139
|
+
servings: {
|
|
140
|
+
type: 'number',
|
|
141
|
+
description: 'Number of servings (default: 1)',
|
|
142
|
+
default: 1
|
|
143
|
+
},
|
|
144
|
+
shoppingListId: {
|
|
145
|
+
type: 'number',
|
|
146
|
+
description: 'ID of the shopping list to add to (default: 1). Most users have only one shopping list with ID 1.',
|
|
147
|
+
default: 1
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
required: ['recipeId']
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
(() => {
|
|
154
|
+
const { toolSubConfigs } = config.parseToolConfiguration();
|
|
155
|
+
const subConfigs = toolSubConfigs?.get('cooked_something');
|
|
156
|
+
const allowNoMealPlan = subConfigs?.get('allow_no_meal_plan') ?? false;
|
|
157
|
+
const allowAlreadyDone = subConfigs?.get('allow_meal_plan_entry_already_done') ?? false;
|
|
158
|
+
return {
|
|
159
|
+
name: 'cooked_something',
|
|
160
|
+
description: 'When the user cooks something this records it as done, consumes recipe ingredients, and creates labeled stock entries with custom portion sizes.',
|
|
161
|
+
inputSchema: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
...(allowNoMealPlan ? {
|
|
165
|
+
recipeId: {
|
|
166
|
+
type: 'number',
|
|
167
|
+
description: 'ID of the recipe to cook directly.'
|
|
168
|
+
}
|
|
169
|
+
} : {
|
|
170
|
+
mealPlanEntryId: {
|
|
171
|
+
type: 'number',
|
|
172
|
+
description: `ID of the meal plan entry.${allowAlreadyDone ? '' : ' Note: This will fail if the meal plan entry is already marked as done (done=1).'}`
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
stockAmounts: {
|
|
176
|
+
type: 'array',
|
|
177
|
+
items: {
|
|
178
|
+
type: 'number',
|
|
179
|
+
minimum: 0.1
|
|
180
|
+
},
|
|
181
|
+
description: 'Array of serving amounts for each stock entry to create (e.g., [1, 2, 2] for 1 single serving + 2 double servings). Total will be used for ingredient consumption.'
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
required: [
|
|
185
|
+
...(allowNoMealPlan ? ['recipeId'] : ['mealPlanEntryId']),
|
|
186
|
+
'stockAmounts'
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
})()
|
|
191
|
+
];
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { BaseToolHandler } from '../base.js';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import apiClient from '../../api/client.js';
|
|
4
|
+
import { StockToolHandlers } from '../stock/handlers.js';
|
|
5
|
+
export class RecipeToolHandlers extends BaseToolHandler {
|
|
6
|
+
stockHandlers = new StockToolHandlers();
|
|
7
|
+
getRecipes = async (args) => {
|
|
8
|
+
const { fields } = args || {};
|
|
9
|
+
if (!fields || !Array.isArray(fields) || fields.length === 0) {
|
|
10
|
+
throw new McpError(ErrorCode.InvalidParams, 'fields parameter is required and must be a non-empty array of field names');
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
// Fetch recipes
|
|
14
|
+
const recipesResponse = await apiClient.get('/objects/recipes', { queryParams: { 'query[]': 'type=normal' } });
|
|
15
|
+
const recipes = recipesResponse.data;
|
|
16
|
+
if (!Array.isArray(recipes)) {
|
|
17
|
+
return this.createSuccessResult([]);
|
|
18
|
+
}
|
|
19
|
+
// Filter recipes to only include requested fields
|
|
20
|
+
const filteredRecipes = recipes.map((recipe) => {
|
|
21
|
+
const filtered = {};
|
|
22
|
+
fields.forEach(field => {
|
|
23
|
+
if (recipe.hasOwnProperty(field)) {
|
|
24
|
+
filtered[field] = recipe[field];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return filtered;
|
|
28
|
+
});
|
|
29
|
+
return this.createSuccessResult(filteredRecipes);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return this.createErrorResult(`Failed to get recipes: ${error.message}`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
getRecipeById = async (args) => {
|
|
36
|
+
const { recipeId } = args || {};
|
|
37
|
+
if (!recipeId) {
|
|
38
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
|
|
39
|
+
}
|
|
40
|
+
return this.handleApiCall(`/objects/recipes/${recipeId}`, 'Get recipe by ID');
|
|
41
|
+
};
|
|
42
|
+
createRecipe = async (args) => {
|
|
43
|
+
const { name, description = '', servings = 1, desiredServings = 1 } = args || {};
|
|
44
|
+
if (!name) {
|
|
45
|
+
throw new McpError(ErrorCode.InvalidParams, 'Recipe name is required');
|
|
46
|
+
}
|
|
47
|
+
const body = {
|
|
48
|
+
name,
|
|
49
|
+
description,
|
|
50
|
+
base_servings: servings,
|
|
51
|
+
desired_servings: desiredServings
|
|
52
|
+
};
|
|
53
|
+
return this.handleApiCall('/objects/recipes', 'Create recipe', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
getRecipeFulfillment = async (args) => {
|
|
59
|
+
const { recipeId, servings = 1 } = args || {};
|
|
60
|
+
if (!recipeId) {
|
|
61
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
|
|
62
|
+
}
|
|
63
|
+
return this.handleApiCall(`/recipes/${recipeId}/fulfillment`, 'Get recipe fulfillment', {
|
|
64
|
+
queryParams: servings !== 1 ? { servings: servings.toString() } : {}
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
getRecipesFulfillment = async () => {
|
|
68
|
+
return this.handleApiCall('/recipes/fulfillment', 'Get all recipes fulfillment');
|
|
69
|
+
};
|
|
70
|
+
consumeRecipe = async (args) => {
|
|
71
|
+
const { recipeId, servings = 1 } = args || {};
|
|
72
|
+
if (!recipeId) {
|
|
73
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
|
|
74
|
+
}
|
|
75
|
+
const body = {
|
|
76
|
+
recipe_id: recipeId,
|
|
77
|
+
servings
|
|
78
|
+
};
|
|
79
|
+
return this.handleApiCall(`/recipes/${recipeId}/consume`, 'Consume recipe', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
body
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
addRecipeProductsToShoppingList = async (args) => {
|
|
85
|
+
const { recipeId } = args || {};
|
|
86
|
+
if (!recipeId) {
|
|
87
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
|
|
88
|
+
}
|
|
89
|
+
return this.handleApiCall(`/recipes/${recipeId}/add-not-fulfilled-products-to-shoppinglist`, 'Add recipe products to shopping list', {
|
|
90
|
+
method: 'POST'
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
addMissingProductsToShoppingList = async (args) => {
|
|
94
|
+
const { recipeId, servings = 1, shoppingListId = 1 } = args || {};
|
|
95
|
+
if (!recipeId) {
|
|
96
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
|
|
97
|
+
}
|
|
98
|
+
const body = {
|
|
99
|
+
servings,
|
|
100
|
+
shopping_list_id: shoppingListId
|
|
101
|
+
};
|
|
102
|
+
return this.handleApiCall(`/recipes/${recipeId}/add-not-fulfilled-products-to-shoppinglist`, 'Add missing products to shopping list', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
body
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
markRecipeFromMealPlanEntryAsCooked = async (args, subConfigs) => {
|
|
108
|
+
const { mealPlanEntryId, recipeId, stockAmounts } = args || {};
|
|
109
|
+
// Sub-configuration options
|
|
110
|
+
const allowMealPlanEntryAlreadyDone = subConfigs?.get('allow_meal_plan_entry_already_done') ?? false;
|
|
111
|
+
const printLabels = subConfigs?.get('print_labels') ?? true;
|
|
112
|
+
const allowNoMealPlan = subConfigs?.get('allow_no_meal_plan') ?? false;
|
|
113
|
+
// Validate required parameters based on mode
|
|
114
|
+
if (!allowNoMealPlan && !mealPlanEntryId) {
|
|
115
|
+
throw new McpError(ErrorCode.InvalidParams, 'mealPlanEntryId is required when allow_no_meal_plan is false.');
|
|
116
|
+
}
|
|
117
|
+
if (allowNoMealPlan && !recipeId) {
|
|
118
|
+
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required when allow_no_meal_plan is true.');
|
|
119
|
+
}
|
|
120
|
+
if (allowNoMealPlan && mealPlanEntryId) {
|
|
121
|
+
throw new McpError(ErrorCode.InvalidParams, 'mealPlanEntryId should not be provided when allow_no_meal_plan is true. Use recipeId instead.');
|
|
122
|
+
}
|
|
123
|
+
if (!stockAmounts || !Array.isArray(stockAmounts) || stockAmounts.length === 0) {
|
|
124
|
+
throw new McpError(ErrorCode.InvalidParams, 'stockAmounts is required and must be a non-empty array of serving amounts.');
|
|
125
|
+
}
|
|
126
|
+
// Validate all stock amounts are positive numbers
|
|
127
|
+
for (let i = 0; i < stockAmounts.length; i++) {
|
|
128
|
+
const amount = stockAmounts[i];
|
|
129
|
+
if (typeof amount !== 'number' || amount <= 0) {
|
|
130
|
+
throw new McpError(ErrorCode.InvalidParams, `stockAmounts[${i}] must be a positive number, got: ${amount}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const completedSteps = [];
|
|
134
|
+
let actualRecipeId;
|
|
135
|
+
let totalServings;
|
|
136
|
+
let mealPlanDate;
|
|
137
|
+
let mealplanShadow;
|
|
138
|
+
try {
|
|
139
|
+
if (allowNoMealPlan) {
|
|
140
|
+
// Direct recipe mode - no meal plan entry involved
|
|
141
|
+
actualRecipeId = recipeId;
|
|
142
|
+
totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0);
|
|
143
|
+
mealPlanDate = new Date().toISOString().split('T')[0];
|
|
144
|
+
mealplanShadow = `${mealPlanDate}#direct-recipe-${actualRecipeId}`;
|
|
145
|
+
completedSteps.push('Using direct recipe mode (no meal plan entry)');
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Meal plan mode - traditional workflow
|
|
149
|
+
const mealPlanResponse = await apiClient.get(`/objects/meal_plan/${mealPlanEntryId}`);
|
|
150
|
+
const mealPlanEntry = mealPlanResponse.data;
|
|
151
|
+
if (!mealPlanEntry) {
|
|
152
|
+
throw new Error(`Meal plan entry ${mealPlanEntryId} not found.`);
|
|
153
|
+
}
|
|
154
|
+
if (mealPlanEntry.done == 1 && !allowMealPlanEntryAlreadyDone) {
|
|
155
|
+
throw new Error(`Meal plan entry ${mealPlanEntryId} is already marked as done. Cannot mark as cooked again.`);
|
|
156
|
+
}
|
|
157
|
+
actualRecipeId = mealPlanEntry.recipe_id;
|
|
158
|
+
totalServings = stockAmounts.reduce((sum, amount) => sum + amount, 0);
|
|
159
|
+
mealPlanDate = mealPlanEntry.day || new Date().toISOString().split('T')[0];
|
|
160
|
+
mealplanShadow = `${mealPlanDate}#${mealPlanEntryId}`;
|
|
161
|
+
// Mark the meal plan entry as done and update recipe_servings
|
|
162
|
+
await apiClient.put(`/objects/meal_plan/${mealPlanEntryId}`, {
|
|
163
|
+
done: 1,
|
|
164
|
+
recipe_servings: totalServings
|
|
165
|
+
});
|
|
166
|
+
completedSteps.push('Meal plan entry marked as done');
|
|
167
|
+
}
|
|
168
|
+
// For direct recipe mode, consume ingredients directly using the recipe ID
|
|
169
|
+
// For meal plan mode, try to find and use the shadow recipe
|
|
170
|
+
if (allowNoMealPlan) {
|
|
171
|
+
// Direct consumption using the recipe ID
|
|
172
|
+
await apiClient.post(`/recipes/${actualRecipeId}/consume`);
|
|
173
|
+
completedSteps.push('Recipe consumed directly');
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Query for the mealplan shadow recipe by name
|
|
177
|
+
const shadowRecipeResponse = await apiClient.get('/objects/recipes', {
|
|
178
|
+
queryParams: { 'query[]': `name=${mealplanShadow}` }
|
|
179
|
+
});
|
|
180
|
+
if (shadowRecipeResponse.data.length === 0) {
|
|
181
|
+
throw new Error(`Mealplan shadow recipe '${mealplanShadow}' not found. Cannot consume ingredients.`);
|
|
182
|
+
}
|
|
183
|
+
const shadowRecipeId = shadowRecipeResponse.data[0].id;
|
|
184
|
+
// Consume ingredients using the shadow recipe ID
|
|
185
|
+
await apiClient.post(`/recipes/${shadowRecipeId}/consume`);
|
|
186
|
+
completedSteps.push('Recipe consumed via meal plan entry');
|
|
187
|
+
}
|
|
188
|
+
// Split stock entry and print labels for each portion
|
|
189
|
+
let stockEntries = { splitEntries: [], labelsPrinted: 0 };
|
|
190
|
+
const recipeResponse = await apiClient.get(`/objects/recipes/${actualRecipeId}`);
|
|
191
|
+
const recipe = recipeResponse.data;
|
|
192
|
+
if (recipe && recipe.product_id) {
|
|
193
|
+
// Get product details, quantity unit, and the most recent stock entry created by recipe consumption
|
|
194
|
+
const [productResponse, entriesResponse] = await Promise.all([
|
|
195
|
+
apiClient.get(`/objects/products/${recipe.product_id}`),
|
|
196
|
+
apiClient.get(`/stock/products/${recipe.product_id}/entries`, {
|
|
197
|
+
queryParams: { order: 'row_created_timestamp:desc', limit: '1' }
|
|
198
|
+
})
|
|
199
|
+
]);
|
|
200
|
+
const product = productResponse.data;
|
|
201
|
+
// Get quantity unit info
|
|
202
|
+
let quantityUnit = null;
|
|
203
|
+
if (product.qu_id_stock) {
|
|
204
|
+
try {
|
|
205
|
+
const quantityUnitResponse = await apiClient.get(`/objects/quantity_units/${product.qu_id_stock}`);
|
|
206
|
+
quantityUnit = quantityUnitResponse.data;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
console.warn('Failed to fetch quantity unit:', error);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Helper function to get correct unit form
|
|
213
|
+
const getUnitForm = (amount) => {
|
|
214
|
+
if (!quantityUnit)
|
|
215
|
+
return '';
|
|
216
|
+
// Handle edge cases
|
|
217
|
+
if (!quantityUnit.name)
|
|
218
|
+
return '';
|
|
219
|
+
if (amount === 1)
|
|
220
|
+
return quantityUnit.name;
|
|
221
|
+
// Use plural form if available, otherwise fallback to singular
|
|
222
|
+
return quantityUnit.name_plural || quantityUnit.name;
|
|
223
|
+
};
|
|
224
|
+
if (entriesResponse.data.length > 0) {
|
|
225
|
+
const originalEntry = entriesResponse.data[0];
|
|
226
|
+
// Use the generic stock splitting helper method
|
|
227
|
+
stockEntries.splitEntries = await this.stockHandlers.splitStockEntry(originalEntry, stockAmounts, getUnitForm);
|
|
228
|
+
// Print labels for all entries (if enabled)
|
|
229
|
+
if (printLabels) {
|
|
230
|
+
for (const entry of stockEntries.splitEntries) {
|
|
231
|
+
try {
|
|
232
|
+
await apiClient.get(`/stock/entry/${entry.stockId}/printlabel`);
|
|
233
|
+
stockEntries.labelsPrinted++;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
console.error(`Failed to print label for stock entry ${entry.stockId}:`, error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return this.createSuccessResult({
|
|
243
|
+
message: `Recipe ${actualRecipeId} cooked (${totalServings} servings consumed, ${stockEntries.splitEntries.length} stock entries created, ${stockEntries.labelsPrinted} labels printed)`,
|
|
244
|
+
stockEntries,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return this.createErrorResult(`Failed to mark meal plan entry as cooked.`, {
|
|
249
|
+
completedSteps,
|
|
250
|
+
reason: error.message,
|
|
251
|
+
help: completedSteps.length > 0
|
|
252
|
+
? `Completed steps: ${completedSteps.join(', ')}. Check the error above and retry if needed.`
|
|
253
|
+
: 'No steps completed. Verify the meal plan entry ID exists and is not already marked as done.'
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export const recipeHandlers = new RecipeToolHandlers();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { recipeToolDefinitions } from './definitions.js';
|
|
2
|
+
import { recipeHandlers } from './handlers.js';
|
|
3
|
+
export const recipeModule = {
|
|
4
|
+
definitions: recipeToolDefinitions,
|
|
5
|
+
handlers: {
|
|
6
|
+
get_recipes: recipeHandlers.getRecipes,
|
|
7
|
+
get_recipe_by_id: recipeHandlers.getRecipeById,
|
|
8
|
+
create_recipe: recipeHandlers.createRecipe,
|
|
9
|
+
get_recipe_fulfillment: recipeHandlers.getRecipeFulfillment,
|
|
10
|
+
get_recipes_fulfillment: recipeHandlers.getRecipesFulfillment,
|
|
11
|
+
consume_recipe: recipeHandlers.consumeRecipe,
|
|
12
|
+
add_recipe_products_to_shopping_list: recipeHandlers.addRecipeProductsToShoppingList,
|
|
13
|
+
add_missing_products_to_shopping_list: recipeHandlers.addMissingProductsToShoppingList,
|
|
14
|
+
cooked_something: recipeHandlers.markRecipeFromMealPlanEntryAsCooked
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export * from './definitions.js';
|
|
18
|
+
export * from './handlers.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { BaseToolHandler } from '../base.js';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
export const shoppingToolDefinitions = [
|
|
4
|
+
{
|
|
5
|
+
name: 'get_shopping_list',
|
|
6
|
+
description: 'Get your current shopping list items.',
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {},
|
|
10
|
+
required: []
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'add_shopping_list_item',
|
|
15
|
+
description: 'Add an item to your shopping list. Use get_products first to find the product ID you want to add.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
productId: {
|
|
20
|
+
type: 'number',
|
|
21
|
+
description: 'ID of the product to add. Use get_products tool to find the correct product ID by searching for the product name in the results.'
|
|
22
|
+
},
|
|
23
|
+
amount: {
|
|
24
|
+
type: 'number',
|
|
25
|
+
description: 'Amount to add to shopping list in the product\'s stock unit (e.g., 2 pieces, 1.5 kg, 750 ml). Ensure you know the product\'s unit before specifying amount. Default: 1',
|
|
26
|
+
default: 1
|
|
27
|
+
},
|
|
28
|
+
shoppingListId: {
|
|
29
|
+
type: 'number',
|
|
30
|
+
description: 'ID of the shopping list to add to (default: 1). Most users have only one shopping list with ID 1.',
|
|
31
|
+
default: 1
|
|
32
|
+
},
|
|
33
|
+
note: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Optional note for the shopping list item'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: ['productId']
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'remove_shopping_list_item',
|
|
43
|
+
description: 'Remove an item from your shopping list. Use get_shopping_list first to find the shopping list item ID.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
shoppingListItemId: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description: 'ID of the shopping list item to remove. Use get_shopping_list tool to find the correct shopping list item ID by looking at the "id" field in the results.'
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
required: ['shoppingListItemId']
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'get_shopping_locations',
|
|
57
|
+
description: 'Get all shopping locations (stores) from your Grocy instance. Use this to find store IDs and names when working with tools that require storeId parameters.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {},
|
|
61
|
+
required: []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
class ShoppingToolHandlers extends BaseToolHandler {
|
|
66
|
+
getShoppingList = async (args) => {
|
|
67
|
+
return this.handleApiCall('/objects/shopping_list', 'Get shopping list items');
|
|
68
|
+
};
|
|
69
|
+
addShoppingListItem = async (args) => {
|
|
70
|
+
const { productId, amount = 1, shoppingListId = 1, note = '' } = args || {};
|
|
71
|
+
if (!productId) {
|
|
72
|
+
throw new McpError(ErrorCode.InvalidParams, 'productId is required');
|
|
73
|
+
}
|
|
74
|
+
const body = {
|
|
75
|
+
product_id: productId,
|
|
76
|
+
amount,
|
|
77
|
+
shopping_list_id: shoppingListId,
|
|
78
|
+
note
|
|
79
|
+
};
|
|
80
|
+
return this.handleApiCall('/objects/shopping_list', 'Add shopping list item', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
body
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
removeShoppingListItem = async (args) => {
|
|
86
|
+
const { shoppingListItemId } = args || {};
|
|
87
|
+
if (!shoppingListItemId) {
|
|
88
|
+
throw new McpError(ErrorCode.InvalidParams, 'shoppingListItemId is required');
|
|
89
|
+
}
|
|
90
|
+
return this.handleApiCall(`/objects/shopping_list/${shoppingListItemId}`, 'Remove shopping list item', {
|
|
91
|
+
method: 'DELETE'
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
getShoppingLocations = async (args) => {
|
|
95
|
+
return this.handleApiCall('/objects/shopping_locations', 'Get all shopping locations');
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const shoppingHandlers = new ShoppingToolHandlers();
|
|
99
|
+
export const shoppingModule = {
|
|
100
|
+
definitions: shoppingToolDefinitions,
|
|
101
|
+
handlers: {
|
|
102
|
+
get_shopping_list: shoppingHandlers.getShoppingList,
|
|
103
|
+
add_shopping_list_item: shoppingHandlers.addShoppingListItem,
|
|
104
|
+
remove_shopping_list_item: shoppingHandlers.removeShoppingListItem,
|
|
105
|
+
get_shopping_locations: shoppingHandlers.getShoppingLocations
|
|
106
|
+
}
|
|
107
|
+
};
|