UNPKG

nons-recipe-server

Version:

An MCP server for recipe management, pantry tracking, and meal discovery

465 lines (415 loc) 13.3 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import fs from 'fs-extra'; // No API key needed for TheMealDB! const MEAL_DB_BASE_URL = 'https://www.themealdb.com/api/json/v1/1'; // File to store pantry data const PANTRY_FILE = 'pantry.json'; class RecipeServer { constructor() { this.server = new Server( { name: 'recipe-server', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); // Load existing pantry data or create empty pantry this.loadPantry(); this.setupToolHandlers(); } // Load pantry data from file async loadPantry() { try { if (await fs.pathExists(PANTRY_FILE)) { this.pantry = await fs.readJson(PANTRY_FILE); } else { this.pantry = []; } } catch (error) { console.error('Error loading pantry:', error); this.pantry = []; } } // Save pantry data to file async savePantry() { try { await fs.writeJson(PANTRY_FILE, this.pantry, { spaces: 2 }); } catch (error) { console.error('Error saving pantry:', error); } } setupToolHandlers() { // Handle tool listing this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'add_ingredient', description: 'Add an ingredient to your pantry', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the ingredient', }, quantity: { type: 'string', description: 'Quantity/amount (e.g., "2 cups", "1 lb")', }, }, required: ['name'], }, }, { name: 'remove_ingredient', description: 'Remove an ingredient from your pantry', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the ingredient to remove', }, }, required: ['name'], }, }, { name: 'list_pantry', description: 'Show all ingredients in your pantry', inputSchema: { type: 'object', properties: {}, }, }, { name: 'find_recipes', description: 'Find recipes based on ingredients in your pantry', inputSchema: { type: 'object', properties: { ingredient: { type: 'string', description: 'Specific ingredient to search for (optional)', }, max_recipes: { type: 'number', description: 'Maximum number of recipes to return (default: 5)', default: 5, }, }, }, }, { name: 'get_recipe_details', description: 'Get full recipe details including instructions', inputSchema: { type: 'object', properties: { recipe_id: { type: 'string', description: 'The recipe ID from TheMealDB', }, }, required: ['recipe_id'], }, }, { name: 'clear_pantry', description: 'Clear all ingredients from your pantry', inputSchema: { type: 'object', properties: {}, }, }, ], }; }); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'add_ingredient': return await this.addIngredient(args.name, args.quantity || ''); case 'remove_ingredient': return await this.removeIngredient(args.name); case 'list_pantry': return await this.listPantry(); case 'find_recipes': return await this.findRecipes(args.ingredient, args.max_recipes || 5); case 'get_recipe_details': return await this.getRecipeDetails(args.recipe_id); case 'clear_pantry': return await this.clearPantry(); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error) { throw new McpError( ErrorCode.InternalError, `Error executing tool ${name}: ${error.message}` ); } }); } // Add ingredient to pantry async addIngredient(name, quantity) { const ingredient = { name: name.toLowerCase().trim(), quantity: quantity.trim(), added_date: new Date().toISOString(), }; // Check if ingredient already exists const existingIndex = this.pantry.findIndex(item => item.name === ingredient.name); if (existingIndex >= 0) { // Update existing ingredient this.pantry[existingIndex] = ingredient; await this.savePantry(); return { content: [ { type: 'text', text: `Updated ${name} in your pantry${quantity ? ` (${quantity})` : ''}`, }, ], }; } else { // Add new ingredient this.pantry.push(ingredient); await this.savePantry(); return { content: [ { type: 'text', text: `Added ${name} to your pantry${quantity ? ` (${quantity})` : ''}`, }, ], }; } } // Remove ingredient from pantry async removeIngredient(name) { const normalizedName = name.toLowerCase().trim(); const initialLength = this.pantry.length; this.pantry = this.pantry.filter(item => item.name !== normalizedName); if (this.pantry.length < initialLength) { await this.savePantry(); return { content: [ { type: 'text', text: `Removed ${name} from your pantry`, }, ], }; } else { return { content: [ { type: 'text', text: `${name} was not found in your pantry`, }, ], }; } } // List all pantry items async listPantry() { if (this.pantry.length === 0) { return { content: [ { type: 'text', text: 'Your pantry is empty. Add some ingredients to get started!', }, ], }; } const pantryList = this.pantry .map(item => `• ${item.name}${item.quantity ? ` (${item.quantity})` : ''}`) .join('\n'); return { content: [ { type: 'text', text: `Your pantry contains:\n${pantryList}`, }, ], }; } // Find recipes based on pantry ingredients async findRecipes(specificIngredient, maxRecipes) { if (this.pantry.length === 0 && !specificIngredient) { return { content: [ { type: 'text', text: 'Your pantry is empty. Add some ingredients first, or specify an ingredient to search for!', }, ], }; } try { let recipes = []; // If specific ingredient is provided, search for that if (specificIngredient) { const response = await axios.get(`${MEAL_DB_BASE_URL}/filter.php?i=${specificIngredient}`); recipes = response.data.meals || []; } else { // Search for recipes using pantry ingredients for (const pantryItem of this.pantry.slice(0, 3)) { // Limit to first 3 ingredients to avoid too many API calls try { const response = await axios.get(`${MEAL_DB_BASE_URL}/filter.php?i=${pantryItem.name}`); if (response.data.meals) { recipes = recipes.concat(response.data.meals); } } catch (error) { console.error(`Error searching for ${pantryItem.name}:`, error.message); } } } // Remove duplicates and limit results const uniqueRecipes = recipes .filter((recipe, index, self) => index === self.findIndex(r => r.idMeal === recipe.idMeal) ) .slice(0, maxRecipes); if (uniqueRecipes.length === 0) { return { content: [ { type: 'text', text: 'No recipes found with your current ingredients. Try adding common ingredients like chicken, beef, rice, or pasta!', }, ], }; } // Format the results let recipeText = `Found ${uniqueRecipes.length} recipes:\n\n`; uniqueRecipes.forEach((recipe, index) => { recipeText += `${index + 1}. **${recipe.strMeal}**\n`; recipeText += ` Recipe ID: ${recipe.idMeal}\n`; recipeText += ` Category: ${recipe.strCategory || 'Unknown'}\n`; recipeText += ` Cuisine: ${recipe.strArea || 'Unknown'}\n`; if (recipe.strMealThumb) { recipeText += ` Image: ${recipe.strMealThumb}\n`; } recipeText += ` (Use get_recipe_details with ID ${recipe.idMeal} for full instructions)\n\n`; }); return { content: [ { type: 'text', text: recipeText, }, ], }; } catch (error) { console.error('Error fetching recipes:', error); return { content: [ { type: 'text', text: 'Sorry, there was an error fetching recipes. Please check your internet connection.', }, ], }; } } // Get detailed recipe information async getRecipeDetails(recipeId) { try { const response = await axios.get(`${MEAL_DB_BASE_URL}/lookup.php?i=${recipeId}`); if (!response.data.meals || response.data.meals.length === 0) { return { content: [ { type: 'text', text: 'Recipe not found. Please check the recipe ID.', }, ], }; } const meal = response.data.meals[0]; // Build ingredients list let ingredients = []; for (let i = 1; i <= 20; i++) { const ingredient = meal[`strIngredient${i}`]; const measure = meal[`strMeasure${i}`]; if (ingredient && ingredient.trim()) { ingredients.push(`• ${measure ? measure.trim() : ''} ${ingredient.trim()}`); } } let recipeDetails = `# ${meal.strMeal}\n\n`; if (meal.strCategory) recipeDetails += `**Category:** ${meal.strCategory}\n`; if (meal.strArea) recipeDetails += `**Cuisine:** ${meal.strArea}\n`; if (meal.strTags) recipeDetails += `**Tags:** ${meal.strTags}\n`; recipeDetails += `\n## Ingredients:\n${ingredients.join('\n')}\n\n`; if (meal.strInstructions) { recipeDetails += `## Instructions:\n${meal.strInstructions}\n\n`; } if (meal.strYoutube) { recipeDetails += `**Video:** ${meal.strYoutube}\n`; } if (meal.strSource) { recipeDetails += `**Source:** ${meal.strSource}\n`; } return { content: [ { type: 'text', text: recipeDetails, }, ], }; } catch (error) { console.error('Error fetching recipe details:', error); return { content: [ { type: 'text', text: 'Sorry, there was an error fetching recipe details. Please check your internet connection.', }, ], }; } } // Clear all pantry items async clearPantry() { this.pantry = []; await this.savePantry(); return { content: [ { type: 'text', text: 'Your pantry has been cleared!', }, ], }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Recipe MCP server running on stdio'); } } const server = new RecipeServer(); server.run().catch(console.error);