nons-recipe-server
Version:
An MCP server for recipe management, pantry tracking, and meal discovery
465 lines (415 loc) • 13.3 kB
JavaScript
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);