import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { MealieClient } from "../client.js";
import { formatErrorResponse } from "../utils.js";
import type { Recipe, RecipeIngredient, RecipeInstruction, RecipeNote, StructuredIngredientInput } from "../types/index.js";
const structuredIngredientSchema = z.object({
quantity: z.number().optional().describe("The quantity of the ingredient (e.g., 2)"),
unit: z.string().optional().describe("The unit of measurement (e.g., 'cups', 'tablespoons')"),
food: z.string().optional().describe("The food item name (e.g., 'flour', 'chicken breast'). Will be auto-created if it doesn't exist."),
note: z.string().optional().describe("Additional notes about the ingredient (e.g., 'sifted', 'diced')"),
});
const ingredientSchema = z.union([
z.string().describe("Plain text ingredient (e.g., '2 cups flour, sifted')"),
structuredIngredientSchema.describe("Structured ingredient with separate quantity, unit, food, and note fields"),
]);
const noteSchema = z.object({
title: z.string().describe("The title/heading for the note"),
text: z.string().describe("The content/body of the note"),
});
type IngredientInput = string | StructuredIngredientInput;
async function resolveIngredients(
client: MealieClient,
ingredients: IngredientInput[]
): Promise<RecipeIngredient[]> {
const resolved: RecipeIngredient[] = [];
for (const ingredient of ingredients) {
if (typeof ingredient === "string") {
resolved.push({
note: ingredient,
disableAmount: true,
});
} else {
const recipeIngredient: RecipeIngredient = {
quantity: ingredient.quantity ?? 1,
note: ingredient.note ?? "",
disableAmount: false,
};
if (ingredient.food) {
const food = await client.findOrCreateFood(ingredient.food);
recipeIngredient.food = {
id: food.id,
name: food.name,
};
recipeIngredient.isFood = true;
}
if (ingredient.unit) {
const unit = await client.findOrCreateUnit(ingredient.unit);
recipeIngredient.unit = {
id: unit.id,
name: unit.name,
};
}
resolved.push(recipeIngredient);
}
}
return resolved;
}
export function registerRecipeTools(
server: McpServer,
client: MealieClient
): void {
server.tool(
"get_recipes",
"Provides a paginated list of recipes with optional filtering. Returns recipe summaries with details like ID, name, description, and image information.",
{
search: z
.string()
.optional()
.describe("Filters recipes by name or description"),
page: z.number().optional().describe("Page number for pagination"),
per_page: z.number().optional().describe("Number of items per page"),
categories: z
.array(z.string())
.optional()
.describe("Filter by specific recipe categories"),
tags: z
.array(z.string())
.optional()
.describe("Filter by specific recipe tags"),
},
async ({ search, page, per_page, categories, tags }) => {
try {
const result = await client.getRecipes({
search,
page,
perPage: per_page,
categories,
tags,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(formatErrorResponse(`Error fetching recipes: ${message}`)),
},
],
};
}
}
);
server.tool(
"get_recipe_detailed",
"Retrieve a specific recipe by its slug identifier. Use this when to get full recipe details for tasks like updating or displaying the recipe. Returns comprehensive recipe details including ingredients, instructions, nutrition information, notes, and associated metadata.",
{
slug: z
.string()
.describe(
"The unique text identifier for the recipe, typically found in recipe URLs or from get_recipes results"
),
},
async ({ slug }) => {
try {
const result = await client.getRecipe(slug);
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Error fetching recipe with slug '${slug}': ${message}`)
),
},
],
};
}
}
);
server.tool(
"get_recipe_concise",
"Retrieve a concise version of a specific recipe by its slug identifier. Use this when you only need a summary of the recipe, such as for meal planning.",
{
slug: z
.string()
.describe(
"The unique text identifier for the recipe, typically found in recipe URLs or from get_recipes results"
),
},
async ({ slug }) => {
try {
const recipe = await client.getRecipe(slug);
const concise = {
name: recipe.name,
slug: recipe.slug,
recipeServings: recipe.recipeServings,
recipeYieldQuantity: recipe.recipeYieldQuantity,
recipeYield: recipe.recipeYield,
totalTime: recipe.totalTime,
rating: recipe.rating,
recipeIngredient: recipe.recipeIngredient,
lastMade: recipe.lastMade,
};
const filtered = Object.fromEntries(
Object.entries(concise).filter(([, v]) => v !== undefined && v !== null)
);
return {
content: [{ type: "text" as const, text: JSON.stringify(filtered, null, 2) }],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Error fetching recipe with slug '${slug}': ${message}`)
),
},
],
};
}
}
);
server.tool(
"create_recipe",
"Create a new recipe with the specified name, ingredients, and instructions. PREFERRED: Use structured ingredients with {quantity, unit, food, note} for proper tracking in Mealie. This enables meal planning and shopping list features. Plain text strings are supported as fallback but lose these benefits. When using structured ingredients with a food name that doesn't exist in the database, it will be automatically created.",
{
name: z.string().describe("The name of the new recipe to be created"),
ingredients: z
.array(ingredientSchema)
.describe(
"A list of ingredients. PREFERRED: Use structured objects with {quantity, unit, food, note} for proper tracking (e.g., {quantity: 2, unit: 'cups', food: 'flour', note: 'sifted'}). Plain text strings (e.g., '2 cups flour, sifted') are supported but don't enable meal planning features."
),
instructions: z
.array(z.string())
.describe("A list of instructions for preparing the recipe"),
notes: z
.array(noteSchema)
.optional()
.describe("Optional recipe notes with title and text (e.g., [{title: 'Tip', text: 'Can substitute almond milk'}])"),
servings: z
.number()
.optional()
.describe("Number of servings this recipe yields (e.g., 4)"),
yield_text: z
.string()
.optional()
.describe("Text description of the yield (e.g., '12 cookies', '1 loaf')"),
description: z
.string()
.optional()
.describe("A description of the recipe (e.g., 'A classic family recipe passed down for generations')"),
},
async ({ name, ingredients, instructions, notes, servings, yield_text, description }) => {
try {
const slug = await client.createRecipe(name);
const recipe = await client.getRecipe(slug);
const recipeIngredients = await resolveIngredients(client, ingredients);
const recipeInstructions: RecipeInstruction[] = instructions.map(
(text) => ({
text,
ingredientReferences: [],
})
);
const recipeNotes: RecipeNote[] = notes || [];
const updateData: Partial<Recipe> = {
...recipe,
recipeIngredient: recipeIngredients,
recipeInstructions: recipeInstructions,
notes: recipeNotes,
};
if (servings !== undefined) {
updateData.recipeServings = servings;
}
if (yield_text !== undefined) {
updateData.recipeYield = yield_text;
}
if (description !== undefined) {
updateData.description = description;
}
const updatedRecipe = await client.updateRecipe(slug, updateData);
return {
content: [
{ type: "text" as const, text: JSON.stringify(updatedRecipe, null, 2) },
],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Error creating recipe '${name}': ${message}`)
),
},
],
};
}
}
);
server.tool(
"update_recipe",
"Replaces the ingredients and instructions of an existing recipe. PREFERRED: Use structured ingredients with {quantity, unit, food, note} for proper tracking in Mealie. This enables meal planning and shopping list features. Plain text strings are supported as fallback but lose these benefits. When using structured ingredients with a food name that doesn't exist in the database, it will be automatically created.",
{
slug: z
.string()
.describe("The unique text identifier for the recipe to be updated"),
ingredients: z
.array(ingredientSchema)
.describe(
"A list of ingredients. PREFERRED: Use structured objects with {quantity, unit, food, note} for proper tracking (e.g., {quantity: 2, unit: 'cups', food: 'flour', note: 'sifted'}). Plain text strings (e.g., '2 cups flour, sifted') are supported but don't enable meal planning features."
),
instructions: z
.array(z.string())
.describe("A list of instructions for preparing the recipe"),
notes: z
.array(noteSchema)
.optional()
.describe("Optional recipe notes with title and text (e.g., [{title: 'Tip', text: 'Can substitute almond milk'}])"),
servings: z
.number()
.optional()
.describe("Number of servings this recipe yields (e.g., 4)"),
yield_text: z
.string()
.optional()
.describe("Text description of the yield (e.g., '12 cookies', '1 loaf')"),
description: z
.string()
.optional()
.describe("A description of the recipe (e.g., 'A classic family recipe passed down for generations')"),
},
async ({ slug, ingredients, instructions, notes, servings, yield_text, description }) => {
try {
const recipe = await client.getRecipe(slug);
const recipeIngredients = await resolveIngredients(client, ingredients);
const recipeInstructions: RecipeInstruction[] = instructions.map(
(text) => ({
text,
ingredientReferences: [],
})
);
const updateData: Partial<Recipe> = {
...recipe,
recipeIngredient: recipeIngredients,
recipeInstructions: recipeInstructions,
};
if (notes !== undefined) {
updateData.notes = notes;
}
if (servings !== undefined) {
updateData.recipeServings = servings;
}
if (yield_text !== undefined) {
updateData.recipeYield = yield_text;
}
if (description !== undefined) {
updateData.description = description;
}
const updatedRecipe = await client.updateRecipe(slug, updateData);
return {
content: [
{ type: "text" as const, text: JSON.stringify(updatedRecipe, null, 2) },
],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Error updating recipe '${slug}': ${message}`)
),
},
],
};
}
}
);
server.tool(
"add_recipe_image",
"Add or replace the image for an existing recipe. Provide either a URL to fetch the image from, or base64-encoded image data.",
{
slug: z.string().describe("The unique text identifier for the recipe"),
image_url: z
.string()
.optional()
.describe("URL to fetch the image from (e.g., 'https://example.com/image.jpg')"),
image_base64: z
.string()
.optional()
.describe("Base64-encoded image data"),
filename: z
.string()
.optional()
.describe("Optional filename for the image (defaults to 'recipe-image.jpg')"),
},
async ({ slug, image_url, image_base64, filename }) => {
try {
// Validate that exactly one of image_url or image_base64 is provided
if (!image_url && !image_base64) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse("Either image_url or image_base64 must be provided")
),
},
],
};
}
if (image_url && image_base64) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse("Provide only one of image_url or image_base64, not both")
),
},
],
};
}
let imageData: Buffer;
let imageName = filename || "recipe-image.jpg";
if (image_url) {
const result = await client.fetchImageFromUrl(image_url);
imageData = result.data;
// Try to extract filename from URL if not provided
if (!filename) {
const urlPath = new URL(image_url).pathname;
const urlFilename = urlPath.split("/").pop();
if (urlFilename && urlFilename.includes(".")) {
imageName = urlFilename;
} else {
// Use content type to determine extension
const ext = result.contentType.split("/").pop() || "jpg";
imageName = `recipe-image.${ext}`;
}
}
} else {
// image_base64 is provided
try {
imageData = Buffer.from(image_base64!, "base64");
} catch {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse("Invalid base64 image data")
),
},
],
};
}
}
await client.uploadRecipeImage(slug, imageData, imageName);
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
success: true,
message: `Successfully uploaded image to recipe '${slug}'`,
filename: imageName,
}),
},
],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Failed to upload image to recipe '${slug}': ${message}`)
),
},
],
};
}
}
);
server.tool(
"delete_recipe",
"Permanently delete a recipe from Mealie. This action cannot be undone.",
{
slug: z
.string()
.describe("The unique text identifier (slug) of the recipe to delete"),
},
async ({ slug }) => {
try {
// First verify the recipe exists
const recipe = await client.getRecipe(slug);
const recipeName = recipe.name;
// Delete the recipe
await client.deleteRecipe(slug);
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
success: true,
message: `Successfully deleted recipe '${recipeName}' (${slug})`,
}),
},
],
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
formatErrorResponse(`Failed to delete recipe '${slug}': ${message}`)
),
},
],
};
}
}
);
}