nutrition-mcp/src/tools.rs
Anthony Merlo 244e141053 feat: scaffold nutrition-mcp server with all 13 tools
Rust MCP server using rmcp SDK with streamable HTTP transport.
- Config from env vars (bind, api_key, db_path, off_user_agent)
- SQLite schema: products (FTS5), entries, daily_goal, weight_log
- OFF API client: Search-a-licious + v3 barcode + SQLite caching
- 13 MCP tools: search_food, get_food_by_barcode, log_food,
  log_custom_food, delete_entry, daily_summary, history,
  list_entries, get_goals, set_goals, log_weight, weight_history,
  bulk_import (stub)
- 14 tracked nutrients (gout + hypertension aware)
- Bearer token auth middleware
- Smoke tested: health, initialize, tools/list, get_goals, daily_summary

Closes nutrition-mcp-dfl
2026-08-20 16:52:11 +01:00

823 lines
No EOL
31 KiB
Rust

// 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<Config>,
off_client: OffClient,
tool_router: ToolRouter<NutritionServer>,
}
impl NutritionServer {
pub fn new(config: Arc<Config>) -> 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<sqlx::SqlitePool, McpError> {
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<u32>,
}
#[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<f64>,
/// Number of servings. Uses OFF serving_quantity if available.
#[serde(default)]
pub servings: Option<f64>,
/// Date in YYYY-MM-DD format. Defaults to today.
#[serde(default)]
pub date: Option<String>,
/// Meal type: breakfast, lunch, dinner, snack. Auto-detected from time if omitted.
#[serde(default)]
pub meal: Option<String>,
}
#[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<Value>,
/// Amount consumed in grams
pub grams: f64,
/// Date in YYYY-MM-DD format. Defaults to today.
#[serde(default)]
pub date: Option<String>,
/// Meal type. Auto-detected from time if omitted.
#[serde(default)]
pub meal: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct HistoryParams {
/// Number of days of history (default 7)
#[serde(default)]
pub days: Option<u32>,
}
#[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<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct WeightHistoryParams {
/// Number of days of weight history (default 30)
#[serde(default)]
pub days: Option<u32>,
}
#[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<String>,
}
// ── 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::<u32>().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<String, Value> = 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<String, Value> = 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<CallToolResult, McpError> {
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<SearchFoodParams>,
) -> Result<CallToolResult, McpError> {
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, &params.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(&params.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<Value> = 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<BarcodeParams>,
) -> Result<CallToolResult, McpError> {
let pool = self.pool().await?;
// Check cache
if let Some(cached) = OffClient::cache_get(&pool, &params.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(&params.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<LogFoodParams>,
) -> Result<CallToolResult, McpError> {
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, &params.product_code)
.await
.map_err(|e| McpError::internal_error(format!("Cache lookup failed: {e}"), None))?
{
p
} else {
self.off_client
.get_by_barcode(&params.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(&params.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<LogCustomFoodParams>,
) -> Result<CallToolResult, McpError> {
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(&params.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<DeleteEntryParams>,
) -> Result<CallToolResult, McpError> {
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<DateParams>,
) -> Result<CallToolResult, McpError> {
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<HistoryParams>,
) -> Result<CallToolResult, McpError> {
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<String, Vec<Value>> = 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<DateParams>,
) -> Result<CallToolResult, McpError> {
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<f64> = row.try_get::<Option<f64>, _>("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<CallToolResult, McpError> {
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::<Vec<_>>()
}))
.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<SetGoalsParams>,
) -> Result<CallToolResult, McpError> {
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<LogWeightParams>,
) -> Result<CallToolResult, McpError> {
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<WeightHistoryParams>,
) -> Result<CallToolResult, McpError> {
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<BulkImportParams>,
) -> Result<CallToolResult, McpError> {
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<String, McpError> {
// 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<Value> = Vec::new();
let mut entry_list: Vec<Value> = 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<f64> = 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()
}
}
}