UNPKG

mcp-grocy-api

Version:

Model Context Protocol (MCP) wrapper for grocy API

1,077 lines 82 kB
#!/usr/bin/env node // Load environment variables from .env file import 'dotenv/config'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, InitializeRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import { VERSION, PACKAGE_NAME as SERVER_NAME } from './version.js'; import { startHttpServer } from './server/http-server.js'; import { z } from 'zod'; // Debug output to help identify version and naming issues console.error(`Starting ${SERVER_NAME} server version ${VERSION}`); if (!process.env.GROCY_BASE_URL) { console.error('GROCY_BASE_URL environment variable is not set. Setting default to http://localhost:9283'); process.env.GROCY_BASE_URL = 'http://localhost:9283'; } // Default response size limit: 10KB (10000 bytes) const RESPONSE_SIZE_LIMIT = process.env.REST_RESPONSE_SIZE_LIMIT ? parseInt(process.env.REST_RESPONSE_SIZE_LIMIT, 10) : 10000; if (isNaN(RESPONSE_SIZE_LIMIT) || RESPONSE_SIZE_LIMIT <= 0) { throw new Error('REST_RESPONSE_SIZE_LIMIT must be a positive number'); } // Disabled authentication methods (but keeping references for code compatibility) const AUTH_BASIC_USERNAME = undefined; const AUTH_BASIC_PASSWORD = undefined; const AUTH_BEARER = undefined; // Hardcoded API key header name const AUTH_APIKEY_HEADER_NAME = "GROCY-API-KEY"; const GROCY_APIKEY_VALUE = process.env.GROCY_APIKEY_VALUE; const GROCY_ENABLE_SSL_VERIFY = process.env.GROCY_ENABLE_SSL_VERIFY !== 'false'; const hasBasicAuth = () => false; // Basic auth is disabled const hasBearerAuth = () => false; // Bearer auth is disabled const hasApiKeyAuth = () => !!GROCY_APIKEY_VALUE; // Only check if API key value exists // Function to sanitize headers by removing sensitive values and non-approved headers const sanitizeHeaders = (headers, isFromOptionalParams = false) => { const sanitized = {}; for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); // Always include headers from optional parameters if (isFromOptionalParams) { sanitized[key] = value; continue; } // Handle authentication headers if (lowerKey === 'authorization' || (AUTH_APIKEY_HEADER_NAME && lowerKey === AUTH_APIKEY_HEADER_NAME.toLowerCase())) { sanitized[key] = '[REDACTED]'; continue; } // For headers from config/env const customHeaders = getCustomHeaders(); if (key in customHeaders) { // Show value only for headers that are in the approved list const safeHeaders = new Set([ 'accept', 'accept-language', 'content-type', 'user-agent', 'cache-control', 'if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since' ]); const lowerKey = key.toLowerCase(); sanitized[key] = safeHeaders.has(lowerKey) ? value : '[REDACTED]'; } } return sanitized; }; const normalizeBaseUrl = (url) => url.replace(/\/+$/, ''); const isValidEndpointArgs = (args) => { if (typeof args !== 'object' || args === null) return false; if (!['GET', 'POST', 'PUT', 'DELETE'].includes(args.method)) return false; if (typeof args.endpoint !== 'string') return false; if (args.headers !== undefined && (typeof args.headers !== 'object' || args.headers === null)) return false; // Check if endpoint contains a full URL const urlPattern = /^(https?:\/\/|www\.)/i; if (urlPattern.test(args.endpoint)) { throw new McpError(ErrorCode.InvalidParams, `Invalid endpoint format. Do not include full URLs. Instead of "${args.endpoint}", use just the path (e.g. "/api/users"). ` + `Your path will be resolved to: ${process.env.GROCY_BASE_URL}${args.endpoint.replace(/^\/+|\/+$/g, '')}. ` + `To test a different base URL, update the GROCY_BASE_URL environment variable.`); } return true; }; // Collect custom headers from environment variables const getCustomHeaders = () => { const headers = {}; const headerPrefix = /^header_/i; // Case-insensitive match for 'header_' for (const [key, value] of Object.entries(process.env)) { if (headerPrefix.test(key) && value !== undefined) { // Extract header name after the prefix, preserving case const headerName = key.replace(headerPrefix, ''); headers[headerName] = value; } } return headers; }; class GrocyApiServer { server; axiosInstance; constructor() { this.setupServer(); } async setupServer() { this.server = new Server({ name: SERVER_NAME, version: VERSION, serverUrl: "https://github.com/saya6k/mcp-grocy-api", documentationUrl: "https://github.com/saya6k/mcp-grocy-api/blob/main/README.md" }, { capabilities: { tools: {}, // Empty object instead of boolean resources: {}, // Empty object instead of boolean prompts: {} // Empty object instead of boolean }, }); const https = await import('https'); this.axiosInstance = axios.create({ baseURL: normalizeBaseUrl(process.env.GROCY_BASE_URL), validateStatus: () => true, // Allow any status code httpsAgent: GROCY_ENABLE_SSL_VERIFY ? undefined : new https.Agent({ rejectUnauthorized: false }) }); this.setupToolHandlers(); this.setupResourceHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); this.server.setRequestHandler(InitializeRequestSchema, async (request) => { console.error('[DEBUG] Initialize handler called with request:', JSON.stringify(request)); return { capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: SERVER_NAME, version: VERSION } }; }); // Custom schema for method: 'initialize' (for compatibility with clients that use this method name) const PlainInitializeRequestSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string(), z.number()]).optional(), method: z.literal('initialize'), params: z.any().optional() }); this.server.setRequestHandler(PlainInitializeRequestSchema, async (request) => { console.error('[DEBUG] Plain "initialize" handler called with request:', JSON.stringify(request)); // Extract protocolVersion from request.params or fallback to a default let protocolVersion = '2024-11-05'; if (request.params && typeof request.params.protocolVersion === 'string') { protocolVersion = request.params.protocolVersion; } return { protocolVersion, // Always include protocolVersion as string capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: SERVER_NAME, version: VERSION } }; }); } makeApiRequest = async (endpoint, method = 'GET', body = null, additionalHeaders = {}, isSpecial = false) => { // Enhanced endpoint path handling with better logging (using stderr to avoid breaking JSON responses) console.error(`Original endpoint: ${endpoint}, Method: ${method}, isSpecial: ${isSpecial}`); // Standardize path handling let normalizedEndpoint = endpoint; // Check if endpoint explicitly starts with /api/ - use it as is if (endpoint.startsWith('/api/')) { normalizedEndpoint = endpoint; } // Handle endpoints that start with api/ without leading slash else if (endpoint.startsWith('api/')) { normalizedEndpoint = `/${endpoint}`; } // Special handling for stock operations if isSpecial is true (legacy support) else if (isSpecial && endpoint.includes('stock/products/')) { if (endpoint.startsWith('/')) { normalizedEndpoint = `/api${endpoint}`; } else { normalizedEndpoint = `/api/${endpoint}`; } } // All other endpoints - ensure they start with /api/ else { if (endpoint.startsWith('/')) { normalizedEndpoint = `/api${endpoint}`; } else { normalizedEndpoint = `/api/${endpoint}`; } } console.error(`Final endpoint URL: ${normalizeBaseUrl(process.env.GROCY_BASE_URL)}${normalizedEndpoint}`); const config = { method, url: normalizedEndpoint, headers: { ...additionalHeaders, 'Accept': 'application/json', 'Content-Type': 'application/json' }, // Add timeout to prevent hanging connections timeout: 30000 // 30 seconds timeout }; if (['POST', 'PUT'].includes(method) && body) { config.data = body; console.error(`Request body: ${JSON.stringify(body)}`); } // Only apply API Key authentication if (hasApiKeyAuth()) { config.headers = { ...config.headers, [AUTH_APIKEY_HEADER_NAME]: GROCY_APIKEY_VALUE }; } try { console.error(`Making ${method} request to ${normalizeBaseUrl(process.env.GROCY_BASE_URL)}${normalizedEndpoint}`); const response = await this.axiosInstance.request(config); if (response.status >= 400) { console.error(`API error (${response.status}): ${JSON.stringify(response.data)}`); throw new Error(`API error (${response.status}): ${JSON.stringify(response.data)}`); } return response.data; } catch (error) { // Improve error handling with more detailed error messages if (axios.isAxiosError(error)) { if (error.code === 'ECONNABORTED') { console.error('API request timeout:', error.message); throw new Error(`Connection timeout: The server took too long to respond. Please check your network connection or server availability.`); } else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) { console.error('API connection reset:', error.message); throw new Error(`Connection reset: The server unexpectedly closed the connection. This might be due to server overload or network issues.`); } else if (!error.response) { console.error('API network error:', error.message); throw new Error(`Network error: Unable to reach the Grocy server. Please verify that the server is running and accessible.`); } } console.error('API request error:', error); throw error; } }; setupResourceHandlers() { this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: `${SERVER_NAME}://examples`, name: 'Grocy API Usage Examples', description: 'Detailed examples of using the Grocy API', mimeType: 'text/markdown' }, { uri: `${SERVER_NAME}://response-format`, name: 'Response Format Documentation', description: 'Documentation of the response format and structure', mimeType: 'text/markdown' }, { uri: `${SERVER_NAME}://config`, name: 'Configuration Documentation', description: 'Documentation of all configuration options and how to use them', mimeType: 'text/markdown' } ] })); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uriPattern = new RegExp(`^${SERVER_NAME}://(.+)$`); const match = request.params.uri.match(uriPattern); if (!match) { throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI format: ${request.params.uri}`); } const resource = match[1]; const fs = await import('fs'); const path = await import('path'); try { const url = await import('url'); const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // In the built app, resources are in build/resources // In development, they're in src/resources const resourcePath = path.join(__dirname, 'resources', `${resource}.md`); const content = await fs.promises.readFile(resourcePath, 'utf8'); return { contents: [{ uri: request.params.uri, mimeType: 'text/markdown', text: content }] }; } catch (error) { throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${resource}`); } }); } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_stock_volatile', description: 'Get volatile stock information (due products, overdue products, expired products, missing products).', inputSchema: { type: 'object', properties: { includeDetails: { type: 'boolean', description: 'Whether to include additional details about each stock item' } }, required: [], }, }, { name: 'get_shopping_list', description: 'Get your current shopping list items.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_chores', description: 'Get all chores from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_tasks', description: 'Get all tasks from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_locations', description: 'Get all storage locations from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_shopping_locations', description: 'Get all shopping locations (stores) from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_product_groups', description: 'Get all product groups from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_quantity_units', description: 'Get all quantity units from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_users', description: 'Get all users from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_recipes_fulfillment', description: 'Get fulfillment information for all recipes.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'add_recipe_products_to_shopping_list', description: 'Add not fulfilled products of a recipe to the shopping list.', inputSchema: { type: 'object', properties: { recipeId: { type: 'string', description: 'ID of the recipe' } }, required: ['recipeId'], }, }, { name: 'undo_action', description: 'Undo an action for different entity types (chores, batteries, tasks).', inputSchema: { type: 'object', properties: { entityType: { type: 'string', description: 'Type of entity (chores, batteries, tasks)', enum: ['chores', 'batteries', 'tasks'] }, id: { type: 'string', description: 'ID of the execution, charge cycle, or task' } }, required: ['entityType', 'id'], }, }, { name: 'get_meal_plan', description: 'Get your meal plan data from Grocy instance.', inputSchema: { type: 'object', properties: { startDate: { type: 'string', description: 'Optional start date in YYYY-MM-DD format. Defaults to today.' }, days: { type: 'number', description: 'Optional number of days to retrieve. Defaults to 7.' } }, required: [], }, }, { name: 'get_products', description: 'Get all products from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_recipes', description: 'Get all recipes from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_stock', description: 'Get current stock from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_batteries', description: 'Get all batteries from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_equipment', description: 'Get all equipment from your Grocy instance.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'add_shopping_list_item', description: 'Add an item to your shopping list.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to add' }, amount: { type: 'number', description: 'Amount to add (default: 1)', default: 1 }, shoppingListId: { type: 'number', description: 'ID of the shopping list to add to (default: 1)', default: 1 }, note: { type: 'string', description: 'Optional note for the shopping list item' } }, required: ['productId'], }, }, { name: 'add_recipe_to_meal_plan', description: 'Add a recipe to the meal plan.', inputSchema: { type: 'object', properties: { recipeId: { type: 'number', description: 'ID of the recipe to add' }, day: { type: 'string', description: 'Day to add the recipe to in YYYY-MM-DD format (default: today)' }, servings: { type: 'number', description: 'Number of servings (default: 1)', default: 1 } // Removed 'section' parameter as it's not supported by the Grocy API }, required: ['recipeId'], }, }, { name: 'inventory_product', description: 'Track a product inventory (set current stock amount).', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to inventory' }, newAmount: { type: 'number', description: 'The new total amount in stock' }, bestBeforeDate: { type: 'string', description: 'Best before date in YYYY-MM-DD format (default: today + 1 year)' }, locationId: { type: 'number', description: 'ID of the location (optional)' }, note: { type: 'string', description: 'Optional note' } }, required: ['productId', 'newAmount'], }, }, { name: 'create_recipe', description: 'Create a new recipe in your Grocy instance.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the recipe' }, description: { type: 'string', description: 'Description of the recipe' }, servings: { type: 'number', description: 'Number of servings (default: 1)', default: 1 }, baseServingAmount: { type: 'number', description: 'Base serving amount (default: 1)', default: 1 }, desiredServings: { type: 'number', description: 'Number of desired servings (default: 1)', default: 1 } }, required: ['name'], }, }, { name: 'purchase_product', description: 'Track a product purchase in your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to purchase' }, amount: { type: 'number', description: 'Amount to purchase (default: 1)', default: 1 }, bestBeforeDate: { type: 'string', description: 'Best before date in YYYY-MM-DD format (default: today + 1 year)' }, price: { type: 'number', description: 'Price of the purchase (optional)' }, storeId: { type: 'number', description: 'ID of the store where purchased (optional)' }, locationId: { type: 'number', description: 'ID of the storage location (optional)' }, note: { type: 'string', description: 'Optional note' } }, required: ['productId'], }, }, { name: 'consume_product', description: 'Track consumption of a product in your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to consume' }, amount: { type: 'number', description: 'Amount to consume (default: 1)', default: 1 }, spoiled: { type: 'boolean', description: 'Whether the product is spoiled (default: false)', default: false }, recipeId: { type: 'number', description: 'ID of the recipe if consuming for a recipe (optional)' }, locationId: { type: 'number', description: 'ID of the location to consume from (optional)' }, note: { type: 'string', description: 'Optional note' } }, required: ['productId'], }, }, { name: 'track_chore_execution', description: 'Track execution of a chore in your Grocy instance.', inputSchema: { type: 'object', properties: { choreId: { type: 'number', description: 'ID of the chore that was executed' }, executedBy: { type: 'number', description: 'ID of the user who executed the chore (optional)' }, tracked_time: { type: 'string', description: 'When the chore was executed in YYYY-MM-DD HH:MM:SS format (default: now)' }, note: { type: 'string', description: 'Optional note' } }, required: ['choreId'], }, }, { name: 'complete_task', description: 'Mark a task as completed in your Grocy instance.', inputSchema: { type: 'object', properties: { taskId: { type: 'number', description: 'ID of the task to complete' }, note: { type: 'string', description: 'Optional note' } }, required: ['taskId'], }, }, { name: 'transfer_product', description: 'Transfer a product from one location to another in your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to transfer' }, amount: { type: 'number', description: 'Amount to transfer (default: 1)', default: 1 }, locationIdFrom: { type: 'number', description: 'ID of the source location (required)' }, locationIdTo: { type: 'number', description: 'ID of the destination location (required)' }, note: { type: 'string', description: 'Optional note for this transfer' } }, required: ['productId', 'locationIdFrom', 'locationIdTo'], }, }, { name: 'get_price_history', description: 'Get the price history of a product from your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to get price history for' } }, required: ['productId'], }, }, { name: 'open_product', description: 'Mark a product as opened in your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to mark as opened (alternative to stockEntryId)' }, stockEntryId: { type: 'number', description: 'ID of the specific stock entry to mark as opened (more precise than productId)' }, amount: { type: 'number', description: 'Amount to mark as opened (default: 1)', default: 1 }, note: { type: 'string', description: 'Optional note' } } }, }, { name: 'get_stock_by_location', description: 'Get all stock from a specific location in your Grocy instance.', inputSchema: { type: 'object', properties: { locationId: { type: 'number', description: 'ID of the location to get stock for' } }, required: ['locationId'], }, }, { name: 'consume_recipe', description: 'Consume all ingredients needed for a recipe in your Grocy instance.', inputSchema: { type: 'object', properties: { recipeId: { type: 'number', description: 'ID of the recipe to consume' }, servings: { type: 'number', description: 'Number of servings to consume (default: 1)', default: 1 } }, required: ['recipeId'], }, }, { name: 'charge_battery', description: 'Track charging of a battery in your Grocy instance.', inputSchema: { type: 'object', properties: { batteryId: { type: 'number', description: 'ID of the battery that was charged' }, trackedTime: { type: 'string', description: 'When the battery was charged in YYYY-MM-DD HH:MM:SS format (default: now)' }, note: { type: 'string', description: 'Optional note' } }, required: ['batteryId'], }, }, { name: 'add_missing_products_to_shopping_list', description: 'Add all missing products for a recipe to your shopping list.', inputSchema: { type: 'object', properties: { recipeId: { type: 'number', description: 'ID of the recipe to add missing products for' }, servings: { type: 'number', description: 'Number of servings (default: 1)', default: 1 }, shoppingListId: { type: 'number', description: 'ID of the shopping list to add to (default: 1)', default: 1 } }, required: ['recipeId'], }, }, { name: 'get_recipe_fulfillment', description: 'Get stock fulfillment information for a recipe.', inputSchema: { type: 'object', properties: { recipeId: { type: 'number', description: 'ID of the recipe to check fulfillment for' }, servings: { type: 'number', description: 'Number of servings (default: 1)', default: 1 } }, required: ['recipeId'], }, }, { name: 'remove_shopping_list_item', description: 'Remove an item from your shopping list.', inputSchema: { type: 'object', properties: { shoppingListItemId: { type: 'number', description: 'ID of the shopping list item to remove' } }, required: ['shoppingListItemId'], }, }, { name: 'call_grocy_api', description: 'Call a specific Grocy API endpoint with custom parameters.', inputSchema: { type: 'object', properties: { endpoint: { type: 'string', description: 'Grocy API endpoint to call (e.g., "objects/products"). Do not include /api/ prefix.' }, method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method to use', default: 'GET' }, body: { type: 'object', description: 'Optional request body for POST/PUT requests' } }, required: ['endpoint'], }, }, { name: 'test_request', description: `Test a REST API endpoint and get detailed response information. Base URL: ${normalizeBaseUrl(process.env.GROCY_BASE_URL)} | SSL Verification ${GROCY_ENABLE_SSL_VERIFY ? 'enabled' : 'disabled'} (see config resource for SSL settings) | Authentication: ${hasApiKeyAuth() ? `API Key using header: GROCY-API-KEY` : 'No authentication configured'}`, inputSchema: { type: 'object', properties: { method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method to use', }, endpoint: { type: 'string', description: `Endpoint path (e.g. "/users"). Do not include full URLs - only the path.`, }, body: { type: 'object', description: 'Optional request body for POST/PUT requests', }, headers: { type: 'object', description: 'Optional request headers for one-time use.', additionalProperties: { type: 'string' } } }, required: ['method', 'endpoint'], }, }, { name: 'get_product_entries', description: 'Get all stock entries for a specific product in your Grocy instance.', inputSchema: { type: 'object', properties: { productId: { type: 'number', description: 'ID of the product to get stock entries for' } }, required: ['productId'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case 'test_request': return await this.handleTestRequest(request); case 'call_grocy_api': return await this.handleCustomGrocyApiCall(request); case 'open_product': return await this.handleOpenProduct(request); case 'get_stock_volatile': return await this.handleGrocyApiCall('/api/stock/volatile', 'Get volatile stock information', request.params.arguments?.includeDetails ? { query_params: { include_details: "true" } } : {}); case 'get_shopping_list': return await this.handleGrocyApiCall('/objects/shopping_list', 'Get shopping list items'); case 'get_chores': return await this.handleGrocyApiCall('/objects/chores', 'Get all chores'); case 'get_tasks': return await this.handleGrocyApiCall('/objects/tasks', 'Get all tasks'); case 'get_locations': return await this.handleGrocyApiCall('/objects/locations', 'Get all storage locations'); case 'get_shopping_locations': return await this.handleGrocyApiCall('/objects/shopping_locations', 'Get all shopping locations'); case 'get_product_groups': return await this.handleGrocyApiCall('/objects/product_groups', 'Get all product groups'); case 'get_quantity_units': return await this.handleGrocyApiCall('/objects/quantity_units', 'Get all quantity units'); case 'get_users': return await this.handleGrocyApiCall('/users', 'Get all users'); case 'get_recipe_fulfillment': return await this.handleGetRecipeFulfillment(request); case 'get_recipes_fulfillment': return await this.handleGetRecipesFulfillment(request); case 'add_recipe_products_to_shopping_list': return await this.handleAddRecipeProductsToShoppingList(request); case 'undo_action': return await this.handleUndoAction(request); case 'get_meal_plan': const startDate = request.params.arguments?.startDate || new Date().toISOString().split('T')[0]; const days = request.params.arguments?.days || 7; return await this.handleGrocyApiCall(`/objects/meal_plan?query%5B%5D=day%3E%3D${startDate}&limit=${days}`, 'Get meal plan'); case 'get_products': return await this.handleGrocyApiCall('/objects/products', 'Get all products'); case 'get_recipes': return await this.handleGrocyApiCall('/objects/recipes', 'Get all recipes'); case 'get_stock': return await this.handleGrocyApiCall('/stock', 'Get current stock'); case 'get_batteries': return await this.handleGrocyApiCall('/objects/batteries', 'Get all batteries'); case 'get_equipment': return await this.handleGrocyApiCall('/objects/equipment', 'Get all equipment'); case 'add_shopping_list_item': const { productId: shoppingProductId, amount: shoppingAmount = 1, shoppingListId = 1, note: shoppingNote = '' } = request.params.arguments || {}; if (!shoppingProductId) { throw new McpError(ErrorCode.InvalidParams, 'productId is required'); } const shoppingListItemData = { product_id: shoppingProductId, amount: shoppingAmount, shopping_list_id: shoppingListId, note: shoppingNote }; return await this.handleGrocyApiCall('/objects/shopping_list', 'Add shopping list item', { method: 'POST', body: shoppingListItemData }); case 'add_recipe_to_meal_plan': const { recipeId: mealPlanRecipeId, day = new Date().toISOString().split('T')[0], servings: mealPlanServings = 1 } = request.params.arguments || {}; if (!mealPlanRecipeId) { throw new McpError(ErrorCode.InvalidParams, 'recipeId is required'); } const mealPlanData = { day: day, recipe_id: mealPlanRecipeId, recipe_servings: mealPlanServings, type: "recipe" // Add the type field to specify this is a recipe entry }; return await this.handleGrocyApiCall('/objects/meal_plan', 'Add recipe to meal plan', { method: 'POST', body: mealPlanData }); case 'inventory_prod