// MCP tools: all 12 tools exposed to MCP clients. // // Food lookup: search_food, get_food_by_barcode // Logging: log_food, log_custom_food, delete_entry // Summary: daily_summary, history, list_entries // Goals: get_goals, set_goals // Weight: log_weight, weight_history // Future: bulk_import (stub) use std::sync::Arc; use rmcp::{ ErrorData as McpError, ServerHandler, handler::server::{ router::tool::ToolRouter, wrapper::Parameters, }, model::*, schemars, tool, tool_handler, tool_router, }; use serde::Deserialize; use serde_json::{json, Value}; use sqlx::Row; use crate::api::{OffClient, Product}; use crate::config::Config; use crate::db::{self, TRACKED_NUTRIENTS}; /// The MCP server handler. Each client session gets its own instance, /// but they share the same Config (which contains the DB path — the pool /// is created per-session to keep the struct Clone-able). #[derive(Clone)] pub struct NutritionServer { config: Arc, off_client: OffClient, tool_router: ToolRouter, } impl NutritionServer { pub fn new(config: Arc) -> Self { let off_client = OffClient::new(&config.off_user_agent); Self { config, off_client, tool_router: Self::tool_router(), } } /// Get a DB pool. Creates one on demand (cheap — SQLite is file-based). async fn pool(&self) -> Result { db::init_database(&self.config.db_path) .await .map_err(|e| McpError::internal_error(format!("DB init failed: {e}"), None)) } } // ── Tool parameter structs ────────────────────────────────────────── #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct SearchFoodParams { /// Food name to search for (e.g. "chicken breast", "coca cola") pub query: String, /// Number of results to return (default 10, max 50) #[serde(default)] pub page_size: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct BarcodeParams { /// Product barcode (EAN-13, UPC, etc.) pub barcode: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct LogFoodParams { /// Product code (barcode) from OFF — use search_food or get_food_by_barcode first pub product_code: String, /// Amount in grams. If provided, takes precedence over servings. #[serde(default)] pub grams: Option, /// Number of servings. Uses OFF serving_quantity if available. #[serde(default)] pub servings: Option, /// Date in YYYY-MM-DD format. Defaults to today. #[serde(default)] pub date: Option, /// Meal type: breakfast, lunch, dinner, snack. Auto-detected from time if omitted. #[serde(default)] pub meal: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct LogCustomFoodParams { /// Food name (e.g. "homemade salad", "restaurant pizza") pub name: String, /// Calories per 100g pub calories: f64, /// Nutrients per 100g as JSON object. Keys: protein, carbohydrates, fat, fiber, sugars, /// saturated_fat, salt, fructose, alcohol, potassium, calcium, magnesium, cholesterol. /// All values in grams or mg (matching OFF _100g units). Calories separate. #[serde(default)] pub nutriments: Option, /// Amount consumed in grams pub grams: f64, /// Date in YYYY-MM-DD format. Defaults to today. #[serde(default)] pub date: Option, /// Meal type. Auto-detected from time if omitted. #[serde(default)] pub meal: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DeleteEntryParams { /// Entry ID (from list_entries) pub id: i64, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DateParams { /// Date in YYYY-MM-DD format. Defaults to today. #[serde(default)] pub date: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct HistoryParams { /// Number of days of history (default 7) #[serde(default)] pub days: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct SetGoalsParams { /// JSON object with nutrient goals. Partial updates are merged with existing goals. /// Keys: energy_kcal, protein, carbohydrates, fat, fiber, sugars, saturated_fat, /// salt, fructose, alcohol, potassium, calcium, magnesium, cholesterol. pub goals: Value, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct LogWeightParams { /// Body weight in kilograms pub weight_kg: f64, /// Date in YYYY-MM-DD format. Defaults to today. #[serde(default)] pub date: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct WeightHistoryParams { /// Number of days of weight history (default 30) #[serde(default)] pub days: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct BulkImportParams { /// URL to a Parquet file (future: HuggingFace dataset). Not yet implemented. #[serde(default)] pub parquet_url: Option, } // ── Helper functions ──────────────────────────────────────────────── fn today() -> String { chrono::Local::now().format("%Y-%m-%d").to_string() } fn detect_meal() -> String { let hour = chrono::Local::now().format("%H").to_string().parse::().unwrap_or(12); match hour { h if h < 11 => "breakfast".to_string(), h if h < 15 => "lunch".to_string(), h if h < 21 => "dinner".to_string(), _ => "snack".to_string(), } } /// Scale nutrient JSON from per-100g to actual portion. /// multiplies each numeric value by (grams / 100). fn scale_nutriments(nutriments: &Value, grams: f64) -> Value { let factor = grams / 100.0; if let Some(obj) = nutriments.as_object() { let scaled: serde_json::Map = obj .iter() .map(|(k, v)| { let scaled_v = if let Some(n) = v.as_f64() { json!(n * factor) } else { v.clone() }; (k.clone(), scaled_v) }) .collect(); Value::Object(scaled) } else { nutriments.clone() } } /// Build a summary JSON from a list of entries' nutriments. /// Sums all tracked nutrients, returns "no data" for missing ones. fn sum_nutriments(entries_nutriments: &[Value]) -> Value { let mut totals: serde_json::Map = serde_json::Map::new(); for (_, tracked_key) in TRACKED_NUTRIENTS { let mut sum = 0.0; let mut has_data = false; for entry_nutriments in entries_nutriments { if let Some(val) = entry_nutriments.get(tracked_key).and_then(|v| v.as_f64()) { sum += val; has_data = true; } } let display_key = tracked_key.replace("_100g", ""); totals.insert( display_key, if has_data { json!(sum) } else { json!("no data") }, ); } Value::Object(totals) } /// Format a product for display in tool results. fn product_to_json(p: &Product) -> Value { let nutriments: Value = serde_json::from_str(&p.nutriments).unwrap_or(json!({})); json!({ "code": p.code, "product_name": p.product_name, "brands": p.brands, "nutriments_per_100g": nutriments, "serving_quantity": p.serving_quantity, "serving_size": p.serving_size, "image_url": p.image_url, }) } fn ok(text: String) -> Result { Ok(CallToolResult::success(vec![Content::text(text)])) } // ── MCP tool implementations ──────────────────────────────────────── #[tool_router] impl NutritionServer { // ── Food lookup ────────────────────────────────────────────── #[tool( description = "Search for food products by name using OpenFood Facts full-text search. \ Returns products with nutrition data per 100g. Results are cached locally. \ Use this when the user mentions a food item by name." )] async fn search_food( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let page_size = params.page_size.unwrap_or(10).min(50); // Try cache first (FTS5) let cached = OffClient::cache_search(&pool, ¶ms.query, page_size) .await .map_err(|e| McpError::internal_error(format!("Cache search failed: {e}"), None))?; let products = if !cached.is_empty() { tracing::debug!("Cache hit for '{}': {} results", params.query, cached.len()); cached } else { tracing::debug!("Cache miss for '{}', querying OFF", params.query); let results = self .off_client .search(¶ms.query, page_size) .await .map_err(|e| McpError::internal_error(format!("OFF search failed: {e}"), None))?; // Cache the results let _ = OffClient::cache_products(&pool, &results).await; results }; if products.is_empty() { return ok(format!("No products found for \"{}\"", params.query)); } let results: Vec = products.iter().map(product_to_json).collect(); ok(serde_json::to_string_pretty(&json!({ "query": params.query, "count": results.len(), "products": results })) .unwrap_or_default()) } #[tool( description = "Look up a food product by barcode (EAN-13, UPC) using OpenFood Facts API v3. \ Returns full nutrition data. Result is cached locally." )] async fn get_food_by_barcode( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; // Check cache if let Some(cached) = OffClient::cache_get(&pool, ¶ms.barcode) .await .map_err(|e| McpError::internal_error(format!("Cache lookup failed: {e}"), None))? { return ok(serde_json::to_string_pretty(&product_to_json(&cached)).unwrap_or_default()); } // Query OFF API let product = self .off_client .get_by_barcode(¶ms.barcode) .await .map_err(|e| McpError::internal_error(format!("OFF API failed: {e}"), None))?; match product { Some(p) => { let _ = OffClient::cache_products(&pool, std::slice::from_ref(&p)).await; ok(serde_json::to_string_pretty(&product_to_json(&p)).unwrap_or_default()) } None => ok(format!( "No product found for barcode {}", params.barcode )), } } // ── Logging ────────────────────────────────────────────────── #[tool( description = "Log a food item from OpenFood Facts by product code (barcode). \ Scales nutrients by portion (grams or servings). \ Shows daily summary after logging. \ Always search_food or get_food_by_barcode first to get the product_code." )] async fn log_food( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let date = params.date.unwrap_or_else(today); let meal = params.meal.unwrap_or_else(detect_meal); // Get product from cache or OFF let product = if let Some(p) = OffClient::cache_get(&pool, ¶ms.product_code) .await .map_err(|e| McpError::internal_error(format!("Cache lookup failed: {e}"), None))? { p } else { self.off_client .get_by_barcode(¶ms.product_code) .await .map_err(|e| McpError::internal_error(format!("OFF API failed: {e}"), None))? .ok_or_else(|| { McpError::invalid_params( format!("Product not found: {}", params.product_code), None, ) })? }; // Calculate grams let grams = if let Some(g) = params.grams { g } else if let Some(s) = params.servings { s * product.serving_quantity.unwrap_or(100.0) } else { 100.0 // default to 100g }; // Scale nutriments let nutriments: Value = serde_json::from_str(&product.nutriments).unwrap_or(json!({})); let scaled = scale_nutriments(&nutriments, grams); // Insert entry let result = sqlx::query( r#"INSERT INTO entries (date, meal, product_code, food_name, grams, servings, nutriments) VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&date) .bind(&meal) .bind(¶ms.product_code) .bind(&product.product_name) .bind(grams) .bind(params.servings) .bind(scaled.to_string()) .execute(&pool) .await .map_err(|e| McpError::internal_error(format!("Insert failed: {e}"), None))?; let id = result.last_insert_rowid(); // Get daily summary let summary = self.build_daily_summary(&pool, &date).await?; ok(format!( "✓ Logged: {} ({}g)\n Entry ID: {}\n Meal: {}\n Date: {}\n\n{}", product.product_name, grams, id, meal, date, summary )) } #[tool( description = "Log a custom food item not in OpenFood Facts (homemade meals, restaurant food). \ Provide calories and nutrients per 100g, plus grams consumed." )] async fn log_custom_food( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let date = params.date.unwrap_or_else(today); let meal = params.meal.unwrap_or_else(detect_meal); // Build nutriments JSON — include calories in the energy-kcal_100g field let mut nutriments = params.nutriments.unwrap_or(json!({})); if let Some(obj) = nutriments.as_object_mut() { obj.insert("energy-kcal_100g".to_string(), json!(params.calories)); } else { nutriments = json!({"energy-kcal_100g": params.calories}); } let scaled = scale_nutriments(&nutriments, params.grams); let result = sqlx::query( r#"INSERT INTO entries (date, meal, food_name, grams, nutriments) VALUES (?, ?, ?, ?, ?)"#, ) .bind(&date) .bind(&meal) .bind(¶ms.name) .bind(params.grams) .bind(scaled.to_string()) .execute(&pool) .await .map_err(|e| McpError::internal_error(format!("Insert failed: {e}"), None))?; let id = result.last_insert_rowid(); let summary = self.build_daily_summary(&pool, &date).await?; ok(format!( "✓ Logged: {} ({}g, {} cal/100g)\n Entry ID: {}\n Meal: {}\n Date: {}\n\n{}", params.name, params.grams, params.calories, id, meal, date, summary )) } #[tool( description = "Delete a food log entry by ID. Use list_entries to find the ID first." )] async fn delete_entry( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let result = sqlx::query("DELETE FROM entries WHERE id = ?") .bind(params.id) .execute(&pool) .await .map_err(|e| McpError::internal_error(format!("Delete failed: {e}"), None))?; if result.rows_affected() == 0 { return ok(format!("No entry found with ID {}", params.id)); } ok(format!("✓ Deleted entry {}", params.id)) } // ── Summary & History ──────────────────────────────────────── #[tool( description = "Show daily nutrition summary for a date (default: today). \ Shows all 14 tracked nutrients vs goals, remaining calories/fructose/salt, \ and a list of all entries for the day." )] async fn daily_summary( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let date = params.date.unwrap_or_else(today); let summary = self.build_daily_summary(&pool, &date).await?; ok(summary) } #[tool( description = "Show nutrition history for the last N days (default 7). \ Shows daily totals for key nutrients (calories, protein, fructose, salt, potassium)." )] async fn history( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let days = params.days.unwrap_or(7); let rows = sqlx::query( r#"SELECT date, nutriments FROM entries WHERE date >= date('now', ?) ORDER BY date DESC"#, ) .bind(format!("-{} days", days)) .fetch_all(&pool) .await .map_err(|e| McpError::internal_error(format!("History query failed: {e}"), None))?; // Group by date let mut by_date: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for row in rows { let date: String = row.try_get("date").map_err(|e| McpError::internal_error(e.to_string(), None))?; let nutriments_str: String = row.try_get("nutriments").map_err(|e| McpError::internal_error(e.to_string(), None))?; let n: Value = serde_json::from_str(&nutriments_str).unwrap_or(json!({})); by_date.entry(date).or_default().push(n); } let mut days_summary = Vec::new(); for (date, entries) in by_date.iter().rev() { let totals = sum_nutriments(entries); days_summary.push(json!({ "date": date, "totals": totals, "entries": entries.len(), })); } ok(serde_json::to_string_pretty(&json!({ "days": days, "history": days_summary })) .unwrap_or_default()) } #[tool( description = "List all food log entries for a date (default: today). \ Shows entry IDs needed for delete_entry." )] async fn list_entries( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let date = params.date.unwrap_or_else(today); let rows = sqlx::query( r#"SELECT id, meal, food_name, grams, nutriments, created_at FROM entries WHERE date = ? ORDER BY created_at"#, ) .bind(&date) .fetch_all(&pool) .await .map_err(|e| McpError::internal_error(format!("List query failed: {e}"), None))?; if rows.is_empty() { return ok(format!("No entries for {}", date)); } let mut entries = Vec::new(); for row in rows { let id: i64 = row.try_get("id").map_err(|e| McpError::internal_error(e.to_string(), None))?; let meal: String = row.try_get("meal").map_err(|e| McpError::internal_error(e.to_string(), None))?; let food_name: String = row.try_get("food_name").map_err(|e| McpError::internal_error(e.to_string(), None))?; let grams: Option = row.try_get::, _>("grams").ok().flatten(); let created_at: String = row.try_get("created_at").map_err(|e| McpError::internal_error(e.to_string(), None))?; entries.push(json!({ "id": id, "meal": meal, "food_name": food_name, "grams": grams, "time": created_at, })); } ok(serde_json::to_string_pretty(&json!({ "date": date, "entries": entries })) .unwrap_or_default()) } // ── Goals ──────────────────────────────────────────────────── #[tool( description = "Get current daily nutrition goals. Returns all 14 tracked nutrient targets." )] async fn get_goals(&self) -> Result { let pool = self.pool().await?; let row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(&pool) .await .map_err(|e| McpError::internal_error(format!("Goals query failed: {e}"), None))?; let goals_str: String = row.try_get("goals").map_err(|e| McpError::internal_error(e.to_string(), None))?; let goals: Value = serde_json::from_str(&goals_str).unwrap_or(json!({})); ok(serde_json::to_string_pretty(&json!({ "daily_goals": goals, "tracked_nutrients": TRACKED_NUTRIENTS.iter().map(|(display, off)| { json!({"display": display, "off_field": off}) }).collect::>() })) .unwrap_or_default()) } #[tool( description = "Set daily nutrition goals. Accepts partial updates (merged with existing). \ Keys: energy_kcal, protein, carbohydrates, fat, fiber, sugars, saturated_fat, \ salt, fructose, alcohol, potassium, calcium, magnesium, cholesterol." )] async fn set_goals( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; // Get existing goals let row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(&pool) .await .map_err(|e| McpError::internal_error(format!("Goals query failed: {e}"), None))?; let existing_str: String = row.try_get("goals").map_err(|e| McpError::internal_error(e.to_string(), None))?; let mut existing: Value = serde_json::from_str(&existing_str).unwrap_or(json!({})); // Merge new goals into existing if let (Some(existing_obj), Some(new_obj)) = (existing.as_object_mut(), params.goals.as_object()) { for (k, v) in new_obj { existing_obj.insert(k.clone(), v.clone()); } } // Save sqlx::query("UPDATE daily_goal SET goals = ?, updated_at = datetime('now') WHERE id = 1") .bind(existing.to_string()) .execute(&pool) .await .map_err(|e| McpError::internal_error(format!("Goals update failed: {e}"), None))?; ok(format!("✓ Updated daily goals:\n{}", serde_json::to_string_pretty(&existing).unwrap_or_default())) } // ── Weight ─────────────────────────────────────────────────── #[tool( description = "Log body weight in kilograms for a date (default: today)." )] async fn log_weight( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let date = params.date.unwrap_or_else(today); sqlx::query( r#"INSERT INTO weight_log (date, weight_kg) VALUES (?, ?) ON CONFLICT DO UPDATE SET weight_kg = excluded.weight_kg, created_at = datetime('now')"#, ) .bind(&date) .bind(params.weight_kg) .execute(&pool) .await .map_err(|e| McpError::internal_error(format!("Weight insert failed: {e}"), None))?; ok(format!("✓ Logged weight: {} kg ({})", params.weight_kg, date)) } #[tool( description = "Show weight history for the last N days (default 30)." )] async fn weight_history( &self, Parameters(params): Parameters, ) -> Result { let pool = self.pool().await?; let days = params.days.unwrap_or(30); let rows = sqlx::query( r#"SELECT date, weight_kg FROM weight_log WHERE date >= date('now', ?) ORDER BY date DESC"#, ) .bind(format!("-{} days", days)) .fetch_all(&pool) .await .map_err(|e| McpError::internal_error(format!("Weight history query failed: {e}"), None))?; if rows.is_empty() { return ok(format!("No weight entries in the last {} days", days)); } let mut entries = Vec::new(); for row in rows { let date: String = row.try_get("date").map_err(|e| McpError::internal_error(e.to_string(), None))?; let weight_kg: f64 = row.try_get("weight_kg").map_err(|e| McpError::internal_error(e.to_string(), None))?; entries.push(json!({"date": date, "weight_kg": weight_kg})); } ok(serde_json::to_string_pretty(&json!({ "days": days, "weight_log": entries })) .unwrap_or_default()) } // ── Future: Bulk Import ────────────────────────────────────── #[tool( description = "Bulk import OpenFood Facts data from a Parquet file for offline use. \ NOT YET IMPLEMENTED — stub for future offline mode." )] async fn bulk_import( &self, Parameters(_params): Parameters, ) -> Result { ok( "Not yet implemented. Future: download HuggingFace Parquet export, \ extract relevant columns (code, product_name, brands, nutriments, \ serving_quantity, serving_size, image_url), bulk insert into products \ table, rebuild FTS5 index." .to_string(), ) } } // ── Daily summary helper ──────────────────────────────────────────── impl NutritionServer { async fn build_daily_summary(&self, pool: &sqlx::SqlitePool, date: &str) -> Result { // Fetch entries let rows = sqlx::query( r#"SELECT id, meal, food_name, grams, nutriments FROM entries WHERE date = ? ORDER BY created_at"#, ) .bind(date) .fetch_all(pool) .await .map_err(|e| McpError::internal_error(format!("Summary query failed: {e}"), None))?; let entry_count = rows.len(); let mut entries_nutriments: Vec = Vec::new(); let mut entry_list: Vec = Vec::new(); for row in &rows { let id: i64 = row.try_get("id").map_err(|e| McpError::internal_error(e.to_string(), None))?; let meal: String = row.try_get("meal").map_err(|e| McpError::internal_error(e.to_string(), None))?; let food_name: String = row.try_get("food_name").map_err(|e| McpError::internal_error(e.to_string(), None))?; let grams: Option = row.try_get("grams").unwrap_or(None); let nutriments_str: String = row.try_get("nutriments").map_err(|e| McpError::internal_error(e.to_string(), None))?; let n: Value = serde_json::from_str(&nutriments_str).unwrap_or(json!({})); entries_nutriments.push(n.clone()); entry_list.push(json!({"id": id, "meal": meal, "food_name": food_name, "grams": grams})); } let totals = sum_nutriments(&entries_nutriments); // Get goals let goals_row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(pool) .await .map_err(|e| McpError::internal_error(format!("Goals query failed: {e}"), None))?; let goals_str: String = goals_row.try_get("goals").map_err(|e| McpError::internal_error(e.to_string(), None))?; let goals: Value = serde_json::from_str(&goals_str).unwrap_or(json!({})); // Calculate remaining for key nutrients let remaining = |key: &str| -> Value { let consumed = totals.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); let goal = goals.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); json!(goal - consumed) }; Ok(serde_json::to_string_pretty(&json!({ "date": date, "entries": entry_count, "totals": totals, "goals": goals, "remaining": { "energy_kcal": remaining("energy-kcal"), "protein": remaining("protein"), "fructose": remaining("fructose"), "salt": remaining("salt"), "potassium": remaining("potassium"), }, "entry_list": entry_list })) .unwrap_or_default()) } } // ── ServerHandler impl ────────────────────────────────────────────── #[tool_handler] impl ServerHandler for NutritionServer { fn get_info(&self) -> ServerInfo { ServerInfo { server_info: Implementation { name: "nutrition-mcp".into(), version: "0.1.0".into(), title: Some("Nutrition Tracker".into()), icons: None, website_url: None, }, capabilities: ServerCapabilities::builder() .enable_tools() .build(), instructions: Some( "Personal nutrition tracking MCP server. Tools: search_food (find food by name), \ get_food_by_barcode (lookup by barcode), log_food (log OFF product with portions), \ log_custom_food (log homemade/restaurant food), delete_entry, daily_summary, \ history, list_entries, get_goals, set_goals, log_weight, weight_history, \ bulk_import (stub). Tracks 14 nutrients including gout (fructose, alcohol) and \ hypertension (salt, potassium, calcium, magnesium, cholesterol) markers." .into(), ), ..Default::default() } } }