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
823 lines
No EOL
31 KiB
Rust
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, ¶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<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, ¶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<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, ¶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<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(¶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<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()
|
|
}
|
|
}
|
|
} |