Skip to main content
Glama
neonwatty

Food Tracker MCP Server

by neonwatty
index.ts17.8 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import dotenv from "dotenv"; import { USDAApi } from "./services/usda-api.js"; import { getDb, getGoals, updateGoals, logFood, getDailyLog, getLogsByDateRange, deleteEntry, calculateTotals, closeDb, } from "./services/database.js"; import type { NutritionInfo } from "./types.js"; // Load environment variables dotenv.config(); const USDA_API_KEY = process.env.USDA_API_KEY; if (!USDA_API_KEY) { console.error("Error: USDA_API_KEY environment variable is required"); console.error("Get your free API key at: https://fdc.nal.usda.gov/api-key-signup/"); process.exit(1); } const usdaApi = new USDAApi(USDA_API_KEY); // Initialize database getDb(); // Helper to get today's date in YYYY-MM-DD format function getToday(): string { return new Date().toISOString().split("T")[0]; } // Tool input schemas const SearchFoodSchema = z.object({ query: z.string().describe("Food name or description to search for"), limit: z.number().optional().default(10).describe("Number of results to return (default: 10)"), }); const LogFoodSchema = z.object({ food_name: z.string().describe("Name of the food"), serving_size: z.number().describe("Amount of the serving"), serving_unit: z.string().describe("Unit of measurement (e.g., 'g', 'oz', 'cup', 'piece')"), calories: z.number().describe("Calories for this serving"), protein_g: z.number().optional().describe("Protein in grams"), carbs_g: z.number().optional().describe("Carbohydrates in grams"), fat_g: z.number().optional().describe("Fat in grams"), fiber_g: z.number().optional().describe("Fiber in grams"), meal: z.enum(["breakfast", "lunch", "dinner", "snack"]).optional().describe("Meal type"), date: z.string().optional().describe("Date in YYYY-MM-DD format (default: today)"), fdc_id: z.number().optional().describe("USDA FDC ID if from search"), notes: z.string().optional().describe("Optional notes"), }); const GetDailyLogSchema = z.object({ date: z.string().optional().describe("Date in YYYY-MM-DD format (default: today)"), }); const SetGoalsSchema = z.object({ daily_calories: z.number().optional().describe("Daily calorie goal"), protein_g: z.number().optional().describe("Daily protein goal in grams"), carbs_g: z.number().optional().describe("Daily carbohydrate goal in grams"), fat_g: z.number().optional().describe("Daily fat goal in grams"), }); const GetSummarySchema = z.object({ start_date: z.string().optional().describe("Start date (YYYY-MM-DD)"), end_date: z.string().optional().describe("End date (YYYY-MM-DD)"), period: z.enum(["week", "month"]).optional().describe("Preset period instead of date range"), }); const DeleteEntrySchema = z.object({ entry_id: z.number().describe("ID of the food log entry to delete"), }); // Create server const server = new Server( { name: "food-tracker-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "search_food", description: "Search the USDA FoodData Central database for foods. Returns nutrition information per 100g serving.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Food name or description to search for" }, limit: { type: "number", description: "Number of results (default: 10)" }, }, required: ["query"], }, }, { name: "log_food", description: "Log a food entry to your daily food diary. Include nutrition info from search or estimate.", inputSchema: { type: "object", properties: { food_name: { type: "string", description: "Name of the food" }, serving_size: { type: "number", description: "Amount of the serving" }, serving_unit: { type: "string", description: "Unit (g, oz, cup, piece, etc.)" }, calories: { type: "number", description: "Calories for this serving" }, protein_g: { type: "number", description: "Protein in grams" }, carbs_g: { type: "number", description: "Carbs in grams" }, fat_g: { type: "number", description: "Fat in grams" }, fiber_g: { type: "number", description: "Fiber in grams" }, meal: { type: "string", enum: ["breakfast", "lunch", "dinner", "snack"] }, date: { type: "string", description: "Date YYYY-MM-DD (default: today)" }, fdc_id: { type: "number", description: "USDA FDC ID if from search" }, notes: { type: "string", description: "Optional notes" }, }, required: ["food_name", "serving_size", "serving_unit", "calories"], }, }, { name: "get_daily_log", description: "Get all food entries for a specific day with totals and comparison to goals.", inputSchema: { type: "object", properties: { date: { type: "string", description: "Date YYYY-MM-DD (default: today)" }, }, }, }, { name: "set_goals", description: "Set your daily nutrition goals for calories and macros.", inputSchema: { type: "object", properties: { daily_calories: { type: "number", description: "Daily calorie goal" }, protein_g: { type: "number", description: "Daily protein goal (g)" }, carbs_g: { type: "number", description: "Daily carb goal (g)" }, fat_g: { type: "number", description: "Daily fat goal (g)" }, }, }, }, { name: "get_summary", description: "Get nutrition summary and averages for a date range or period.", inputSchema: { type: "object", properties: { start_date: { type: "string", description: "Start date YYYY-MM-DD" }, end_date: { type: "string", description: "End date YYYY-MM-DD" }, period: { type: "string", enum: ["week", "month"], description: "Preset period" }, }, }, }, { name: "delete_entry", description: "Delete a food log entry by its ID.", inputSchema: { type: "object", properties: { entry_id: { type: "number", description: "ID of the entry to delete" }, }, required: ["entry_id"], }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "search_food": { const { query, limit } = SearchFoodSchema.parse(args); const results = await usdaApi.searchFoods(query, limit); if (results.length === 0) { return { content: [ { type: "text", text: `No foods found matching "${query}". Try a different search term.`, }, ], }; } const formatted = results .map( (food, i) => `${i + 1}. **${food.description}**${food.brandOwner ? ` (${food.brandOwner})` : ""}\n` + ` FDC ID: ${food.fdcId} | Type: ${food.dataType}\n` + ` Per 100g: ${food.nutrition.calories} cal | ` + `P: ${food.nutrition.protein_g}g | C: ${food.nutrition.carbs_g}g | F: ${food.nutrition.fat_g}g` ) .join("\n\n"); return { content: [ { type: "text", text: `Found ${results.length} foods matching "${query}":\n\n${formatted}\n\n` + `*Nutrition values are per 100g. Scale accordingly when logging.*`, }, ], }; } case "log_food": { const input = LogFoodSchema.parse(args); const date = input.date || getToday(); const entry = logFood({ date, meal: input.meal || null, food_name: input.food_name, fdc_id: input.fdc_id || null, serving_size: input.serving_size, serving_unit: input.serving_unit, calories: input.calories, protein_g: input.protein_g ?? null, carbs_g: input.carbs_g ?? null, fat_g: input.fat_g ?? null, fiber_g: input.fiber_g ?? null, notes: input.notes || null, }); // Get updated daily totals const dailyEntries = getDailyLog(date); const totals = calculateTotals(dailyEntries); const goals = getGoals(); let response = `Logged: **${entry.food_name}** (${entry.serving_size} ${entry.serving_unit})\n`; response += `${entry.calories} cal`; if (entry.protein_g) response += ` | P: ${entry.protein_g}g`; if (entry.carbs_g) response += ` | C: ${entry.carbs_g}g`; if (entry.fat_g) response += ` | F: ${entry.fat_g}g`; if (entry.meal) response += `\nMeal: ${entry.meal}`; response += `\n\n**Daily Total (${date}):** ${totals.calories} cal`; if (goals?.daily_calories) { const remaining = goals.daily_calories - totals.calories; response += ` | ${remaining > 0 ? remaining + " remaining" : Math.abs(remaining) + " over goal"}`; } return { content: [{ type: "text", text: response }] }; } case "get_daily_log": { const { date } = GetDailyLogSchema.parse(args); const targetDate = date || getToday(); const entries = getDailyLog(targetDate); const goals = getGoals(); if (entries.length === 0) { return { content: [ { type: "text", text: `No food entries for ${targetDate}. Start logging with the log_food tool!`, }, ], }; } const totals = calculateTotals(entries); // Group by meal const byMeal: Record<string, typeof entries> = {}; for (const entry of entries) { const meal = entry.meal || "unspecified"; if (!byMeal[meal]) byMeal[meal] = []; byMeal[meal].push(entry); } let response = `## Food Log for ${targetDate}\n\n`; for (const [meal, mealEntries] of Object.entries(byMeal)) { response += `### ${meal.charAt(0).toUpperCase() + meal.slice(1)}\n`; for (const entry of mealEntries) { response += `- **${entry.food_name}** (${entry.serving_size} ${entry.serving_unit}) - `; response += `${entry.calories} cal`; if (entry.protein_g) response += ` | P: ${entry.protein_g}g`; response += ` [ID: ${entry.id}]\n`; } response += "\n"; } response += `### Daily Totals\n`; response += `- Calories: ${totals.calories}`; if (goals?.daily_calories) { const pct = Math.round((totals.calories / goals.daily_calories) * 100); response += ` / ${goals.daily_calories} (${pct}%)`; } response += `\n- Protein: ${totals.protein_g}g`; if (goals?.protein_g) response += ` / ${goals.protein_g}g`; response += `\n- Carbs: ${totals.carbs_g}g`; if (goals?.carbs_g) response += ` / ${goals.carbs_g}g`; response += `\n- Fat: ${totals.fat_g}g`; if (goals?.fat_g) response += ` / ${goals.fat_g}g`; response += `\n- Fiber: ${totals.fiber_g}g`; return { content: [{ type: "text", text: response }] }; } case "set_goals": { const input = SetGoalsSchema.parse(args); if (!input.daily_calories && !input.protein_g && !input.carbs_g && !input.fat_g) { const current = getGoals(); return { content: [ { type: "text", text: `**Current Goals:**\n` + `- Calories: ${current?.daily_calories || "not set"}\n` + `- Protein: ${current?.protein_g || "not set"}g\n` + `- Carbs: ${current?.carbs_g || "not set"}g\n` + `- Fat: ${current?.fat_g || "not set"}g`, }, ], }; } const updated = updateGoals(input); return { content: [ { type: "text", text: `**Goals Updated:**\n` + `- Calories: ${updated.daily_calories}\n` + `- Protein: ${updated.protein_g}g\n` + `- Carbs: ${updated.carbs_g}g\n` + `- Fat: ${updated.fat_g}g`, }, ], }; } case "get_summary": { const input = GetSummarySchema.parse(args); let startDate: string; let endDate: string; if (input.period === "week") { const today = new Date(); const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7); startDate = weekAgo.toISOString().split("T")[0]; endDate = today.toISOString().split("T")[0]; } else if (input.period === "month") { const today = new Date(); const monthAgo = new Date(today); monthAgo.setMonth(today.getMonth() - 1); startDate = monthAgo.toISOString().split("T")[0]; endDate = today.toISOString().split("T")[0]; } else if (input.start_date && input.end_date) { startDate = input.start_date; endDate = input.end_date; } else { // Default to last 7 days const today = new Date(); const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7); startDate = weekAgo.toISOString().split("T")[0]; endDate = today.toISOString().split("T")[0]; } const entries = getLogsByDateRange(startDate, endDate); const goals = getGoals(); if (entries.length === 0) { return { content: [ { type: "text", text: `No food entries found between ${startDate} and ${endDate}.`, }, ], }; } // Group by date const byDate: Record<string, typeof entries> = {}; for (const entry of entries) { if (!byDate[entry.date]) byDate[entry.date] = []; byDate[entry.date].push(entry); } const dailyTotals = Object.entries(byDate).map(([date, dayEntries]) => ({ date, ...calculateTotals(dayEntries), })); const numDays = dailyTotals.length; const avgCalories = Math.round( dailyTotals.reduce((sum, d) => sum + d.calories, 0) / numDays ); const avgProtein = Math.round( (dailyTotals.reduce((sum, d) => sum + d.protein_g, 0) / numDays) * 10 ) / 10; const avgCarbs = Math.round( (dailyTotals.reduce((sum, d) => sum + d.carbs_g, 0) / numDays) * 10 ) / 10; const avgFat = Math.round( (dailyTotals.reduce((sum, d) => sum + d.fat_g, 0) / numDays) * 10 ) / 10; let response = `## Nutrition Summary: ${startDate} to ${endDate}\n\n`; response += `**${numDays} days tracked** | ${entries.length} total entries\n\n`; response += `### Daily Averages\n`; response += `- Calories: ${avgCalories}`; if (goals?.daily_calories) { const pct = Math.round((avgCalories / goals.daily_calories) * 100); response += ` (${pct}% of ${goals.daily_calories} goal)`; } response += `\n- Protein: ${avgProtein}g`; if (goals?.protein_g) response += ` / ${goals.protein_g}g goal`; response += `\n- Carbs: ${avgCarbs}g`; if (goals?.carbs_g) response += ` / ${goals.carbs_g}g goal`; response += `\n- Fat: ${avgFat}g`; if (goals?.fat_g) response += ` / ${goals.fat_g}g goal`; response += `\n\n### Daily Breakdown\n`; for (const day of dailyTotals) { response += `- ${day.date}: ${day.calories} cal | P: ${day.protein_g}g | C: ${day.carbs_g}g | F: ${day.fat_g}g\n`; } return { content: [{ type: "text", text: response }] }; } case "delete_entry": { const { entry_id } = DeleteEntrySchema.parse(args); const deleted = deleteEntry(entry_id); if (deleted) { return { content: [{ type: "text", text: `Entry ${entry_id} deleted successfully.` }], }; } else { return { content: [{ type: "text", text: `Entry ${entry_id} not found.` }], }; } } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, }; } }); // Cleanup on exit process.on("SIGINT", () => { closeDb(); process.exit(0); }); process.on("SIGTERM", () => { closeDb(); process.exit(0); }); // Start server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Food Tracker MCP server running on stdio"); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/neonwatty/food-tracker-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server