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
This commit is contained in:
Anthony Merlo 2026-08-20 16:52:11 +01:00
parent 7990757bb3
commit 244e141053
9 changed files with 4360 additions and 5 deletions

View file

@ -0,0 +1 @@
{"id":"int-318d614e9de852a6fde2cb00ed524b17","kind":"field_change","created_at":"2026-08-20T15:52:06.000603613Z","actor":"Anthony Merlo","issue_id":"nutrition-mcp-dfl","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Scaffold complete. Cargo.toml + 4 modules (main, config, db, api, tools). Binary compiles and passes smoke test: health check, MCP initialize, 13 tools listed, get_goals returns correct defaults, daily_summary works, auth middleware blocks wrong key."}}

10
.gitignore vendored
View file

@ -1,6 +1,6 @@
# Beads / Dolt files (added by bd init)
.dolt/
/target
*.db
.beads-credential-key
.beads/proxieddb/
*.db-journal
*.db-wal
*.db-shm
.env

2874
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

49
Cargo.toml Normal file
View file

@ -0,0 +1,49 @@
[package]
name = "nutrition-mcp"
version = "0.1.0"
edition = "2024"
description = "MCP server for personal calorie/nutrient tracking with OpenFood Facts integration"
license = "MIT"
[dependencies]
# MCP protocol
rmcp = { version = "0.7", features = [
"server",
"macros",
"schemars",
"transport-streamable-http-server",
] }
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
# HTTP client (for OFF API)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"
# Web framework (for HTTP transport + auth middleware)
axum = "0.8"
# Error handling
anyhow = "1"
thiserror = "2"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Misc
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
[profile.release]
opt-level = 2
strip = true

241
src/api.rs Normal file
View file

@ -0,0 +1,241 @@
// OpenFood Facts API client: Search-a-licious + v3 barcode + SQLite caching.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::db::DbPool;
/// OFF API client for food lookups.
#[derive(Clone)]
pub struct OffClient {
http: reqwest::Client,
user_agent: String,
}
/// A cached product from OFF (stored in SQLite products table).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Product {
pub code: String,
pub product_name: String,
pub brands: Option<String>,
pub nutriments: String, // JSON blob
pub serving_quantity: Option<f64>,
pub serving_size: Option<String>,
pub image_url: Option<String>,
}
/// Search result from Search-a-licious (simplified).
#[derive(Debug, Deserialize)]
struct SearchResponse {
hits: Vec<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
#[serde(default)]
code: Option<String>,
#[serde(default)]
product_name: Option<String>,
#[serde(default)]
brands: Option<String>,
#[serde(default)]
nutriments: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>,
#[serde(default)]
serving_size: Option<String>,
#[serde(default)]
image_url: Option<String>,
}
/// OFF API v3 product response (simplified).
#[derive(Debug, Deserialize)]
struct V3ProductResponse {
product: V3Product,
}
#[derive(Debug, Deserialize)]
struct V3Product {
code: String,
#[serde(default)]
product_name: Option<String>,
#[serde(default)]
brands: Option<String>,
#[serde(default)]
nutriments: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>,
#[serde(default)]
serving_size: Option<String>,
#[serde(default)]
image_url: Option<String>,
}
impl OffClient {
pub fn new(user_agent: &str) -> Self {
let http = reqwest::Client::builder()
.user_agent(user_agent)
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("failed to build reqwest client");
Self {
http,
user_agent: user_agent.to_string(),
}
}
/// Search products by name using Search-a-licious.
/// Returns up to `page_size` results.
pub async fn search(
&self,
query: &str,
page_size: u32,
) -> Result<Vec<Product>> {
let url = "https://search.openfoodfacts.org/api/v1/search";
let params = [
("q", query.to_string()),
("page_size", page_size.to_string()),
("langs", "en".to_string()),
(
"fields",
"code,product_name,brands,nutriments,serving_quantity,serving_size,image_url"
.to_string(),
),
];
tracing::debug!("OFF search: {} (limit {})", query, page_size);
let resp: SearchResponse = self
.http
.get(url)
.query(&params)
.send()
.await
.context("OFF search request failed")?
.error_for_status()?
.json()
.await
.context("failed to parse OFF search response")?;
let products = resp
.hits
.into_iter()
.filter_map(|h| {
let name = h.product_name.clone().unwrap_or_else(|| "Unknown".to_string());
Some(Product {
code: h.code?,
product_name: name,
brands: h.brands,
nutriments: h
.nutriments
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string()),
serving_quantity: h.serving_quantity,
serving_size: h.serving_size,
image_url: h.image_url,
})
})
.collect();
Ok(products)
}
/// Get a single product by barcode using OFF API v3.
pub async fn get_by_barcode(&self, barcode: &str) -> Result<Option<Product>> {
let url = format!(
"https://world.openfoodfacts.org/api/v3/product/{}",
barcode
);
let params = [
(
"fields",
"code,product_name,brands,nutriments,serving_quantity,serving_size,image_url",
),
];
tracing::debug!("OFF barcode lookup: {}", barcode);
let resp = self.http.get(&url).query(&params).send().await;
match resp {
Ok(r) if r.status().is_success() => {
let v3: V3ProductResponse =
r.json().await.context("failed to parse OFF v3 response")?;
let p = v3.product;
Ok(Some(Product {
code: p.code,
product_name: p.product_name.unwrap_or_else(|| "Unknown".to_string()),
brands: p.brands,
nutriments: p
.nutriments
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string()),
serving_quantity: p.serving_quantity,
serving_size: p.serving_size,
image_url: p.image_url,
}))
}
Ok(r) if r.status().as_u16() == 404 => Ok(None),
Ok(r) => anyhow::bail!("OFF API error: {}", r.status()),
Err(e) => Err(e).context("OFF barcode request failed"),
}
}
/// Cache products in SQLite. Upserts by code.
pub async fn cache_products(pool: &DbPool, products: &[Product]) -> Result<()> {
for p in products {
sqlx::query(
r#"INSERT INTO products (code, product_name, brands, nutriments, serving_quantity, serving_size, image_url, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(code) DO UPDATE SET
product_name = excluded.product_name,
brands = excluded.brands,
nutriments = excluded.nutriments,
serving_quantity = excluded.serving_quantity,
serving_size = excluded.serving_size,
image_url = excluded.image_url,
updated_at = datetime('now')"#,
)
.bind(&p.code)
.bind(&p.product_name)
.bind(&p.brands)
.bind(&p.nutriments)
.bind(p.serving_quantity)
.bind(&p.serving_size)
.bind(&p.image_url)
.execute(pool)
.await?;
}
Ok(())
}
/// Look up a product in the SQLite cache by barcode.
pub async fn cache_get(pool: &DbPool, code: &str) -> Result<Option<Product>> {
let product = sqlx::query_as::<_, Product>(
r#"SELECT code, product_name, brands, nutriments, serving_quantity, serving_size, image_url
FROM products WHERE code = ?"#,
)
.bind(code)
.fetch_optional(pool)
.await?;
Ok(product)
}
/// Search the SQLite cache by product name using FTS5.
pub async fn cache_search(pool: &DbPool, query: &str, limit: u32) -> Result<Vec<Product>> {
let products = sqlx::query_as::<_, Product>(
r#"SELECT p.code, p.product_name, p.brands, p.nutriments, p.serving_quantity, p.serving_size, p.image_url
FROM products_fts fts
JOIN products p ON p.rowid = fts.rowid
WHERE products_fts MATCH ?
LIMIT ?"#,
)
.bind(query)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(products)
}
}

74
src/config.rs Normal file
View file

@ -0,0 +1,74 @@
use std::path::PathBuf;
use anyhow::{Result, bail};
/// Server configuration loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
/// Bind address, e.g. "0.0.0.0:9432"
pub bind: String,
/// API key for Bearer auth
pub api_key: String,
/// Path to SQLite database file
pub db_path: PathBuf,
/// User-Agent header for OpenFood Facts API requests
pub off_user_agent: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
let bind = std::env::var("NUTRITION_MCP_BIND")
.unwrap_or_else(|_| "0.0.0.0:9432".to_string());
let api_key = std::env::var("NUTRITION_MCP_API_KEY")
.unwrap_or_else(|_| {
tracing::warn!("NUTRITION_MCP_API_KEY not set — generating a random key");
uuid::Uuid::new_v4().to_string()
});
let db_path = std::env::var("NUTRITION_MCP_DB_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut p = dirs_data_path();
p.push("nutrition");
p.push("nutrition.db");
p
});
let off_user_agent = std::env::var("NUTRITION_MCP_OFF_USER_AGENT")
.unwrap_or_else(|_| {
"NutritionMCP/0.1.0 (personal use)".to_string()
});
if api_key.is_empty() {
bail!("NUTRITION_MCP_API_KEY must not be empty");
}
Ok(Self {
bind,
api_key,
db_path,
off_user_agent,
})
}
/// Create the parent directory of db_path if it doesn't exist.
pub fn ensure_db_dir(&self) -> Result<()> {
if let Some(parent) = self.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
}
fn dirs_data_path() -> PathBuf {
// Prefer XDG_DATA_HOME, fall back to ~/.local/share
std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let mut p = PathBuf::from(home);
p.push(".local");
p.push("share");
p
})
}

183
src/db.rs Normal file
View file

@ -0,0 +1,183 @@
// Database layer: SQLite schema, connection pool, and queries.
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
pub type DbPool = SqlitePool;
/// 14 tracked nutrients with their OFF field names.
/// Used for daily summaries and goal tracking.
pub const TRACKED_NUTRIENTS: &[(&str, &str)] = &[
("energy_kcal", "energy-kcal_100g"),
("protein", "proteins_100g"),
("carbohydrates", "carbohydrates_100g"),
("fat", "fat_100g"),
("fiber", "fiber_100g"),
("sugars", "sugars_100g"),
("saturated_fat", "saturated-fat_100g"),
("salt", "salt_100g"),
("fructose", "fructose_100g"),
("alcohol", "alcohol_100g"),
("potassium", "potassium_100g"),
("calcium", "calcium_100g"),
("magnesium", "magnesium_100g"),
("cholesterol", "cholesterol_100g"),
];
/// Default daily goals (gout + hypertension aware).
/// Values are in grams or mg as appropriate (matches OFF _100g units).
pub const DEFAULT_GOALS_JSON: &str = r#"{
"energy_kcal": 2500,
"protein": 150,
"carbohydrates": 300,
"fat": 70,
"fiber": 30,
"sugars": 90,
"saturated_fat": 20,
"salt": 6,
"fructose": 15,
"alcohol": 0,
"potassium": 3500,
"calcium": 700,
"magnesium": 300,
"cholesterol": 300
}"#;
/// Initialize the SQLite database: create pool, run migrations.
pub async fn init_database(db_path: &Path) -> Result<DbPool> {
let conn_opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path.display()))?
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(conn_opts)
.await?;
run_migrations(&pool, db_path).await?;
Ok(pool)
}
async fn run_migrations(pool: &DbPool, db_path: &Path) -> Result<()> {
// products — cache of OFF API results
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS products (
code TEXT PRIMARY KEY,
product_name TEXT NOT NULL,
brands TEXT,
nutriments TEXT NOT NULL DEFAULT '{}',
serving_quantity REAL,
serving_size TEXT,
image_url TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"#,
)
.execute(pool)
.await?;
// FTS5 virtual table for fast product name search
sqlx::query(
r#"CREATE VIRTUAL TABLE IF NOT EXISTS products_fts USING fts5(
product_name,
content='products',
content_rowid='rowid'
)"#,
)
.execute(pool)
.await?;
// Triggers to keep FTS5 index in sync with products table
sqlx::query(
r#"CREATE TRIGGER IF NOT EXISTS products_ai AFTER INSERT ON products BEGIN
INSERT INTO products_fts(rowid, product_name) VALUES (new.rowid, new.product_name);
END"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"CREATE TRIGGER IF NOT EXISTS products_ad AFTER DELETE ON products BEGIN
INSERT INTO products_fts(products_fts, rowid, product_name) VALUES ('delete', old.rowid, old.product_name);
END"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"CREATE TRIGGER IF NOT EXISTS products_au AFTER UPDATE ON products BEGIN
INSERT INTO products_fts(products_fts, rowid, product_name) VALUES ('delete', old.rowid, old.product_name);
INSERT INTO products_fts(rowid, product_name) VALUES (new.rowid, new.product_name);
END"#,
)
.execute(pool)
.await?;
// entries — food log (one row per food item consumed)
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
meal TEXT NOT NULL,
product_code TEXT,
food_name TEXT NOT NULL,
grams REAL,
servings REAL,
nutriments TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date)"#,
)
.execute(pool)
.await?;
// daily_goal — single row (id=1), JSON column with all nutrient targets
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS daily_goal (
id INTEGER PRIMARY KEY DEFAULT 1,
goals TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (id = 1)
)"#,
)
.execute(pool)
.await?;
// Seed default goals if not present
sqlx::query(
r#"INSERT OR IGNORE INTO daily_goal (id, goals) VALUES (1, ?)"#,
)
.bind(DEFAULT_GOALS_JSON)
.execute(pool)
.await?;
// weight_log — body weight tracking
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS weight_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
weight_kg REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"CREATE INDEX IF NOT EXISTS idx_weight_log_date ON weight_log(date)"#,
)
.execute(pool)
.await?;
tracing::info!("Database initialized at {}", db_path.display());
Ok(())
}

110
src/main.rs Normal file
View file

@ -0,0 +1,110 @@
//nutrition-mcp: MCP server for personal nutrition tracking
//
//Provides 12 tools for food lookup (OpenFood Facts), meal logging,
//daily summaries, goal management, and weight tracking.
//Runs as a streamable HTTP server with API key auth.
mod api;
mod config;
mod db;
mod tools;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Result;
use axum::{
Router,
extract::State,
http::{HeaderMap, Request, StatusCode},
middleware::{self, Next},
response::Response,
routing::get,
};
use rmcp::transport::{
StreamableHttpServerConfig,
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
};
use config::Config;
use tools::NutritionServer;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let config = Config::from_env()?;
tracing::info!("Nutrition MCP server starting on {}", config.bind);
// Ensure data directory and database exist
config.ensure_db_dir()?;
db::init_database(&config.db_path).await?;
let config = Arc::new(config);
// Create the MCP service — each session gets a fresh NutritionServer
let config_for_factory = config.clone();
let mcp_service: StreamableHttpService<NutritionServer, LocalSessionManager> =
StreamableHttpService::new(
move || {
Ok(NutritionServer::new(config_for_factory.clone()))
},
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
// Auth middleware — checks Authorization: Bearer <key>
let api_key = config.api_key.clone();
let protected_mcp = Router::new()
.nest_service("/mcp", mcp_service)
.layer(middleware::from_fn_with_state(
api_key,
auth_middleware,
));
let app = Router::new()
.route("/health", get(health_check))
.merge(protected_mcp);
let addr: SocketAddr = config.bind.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("Server listening on http://{}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Shutting down...");
})
.await?;
Ok(())
}
/// Health check endpoint — no auth required
async fn health_check() -> &'static str {
"OK"
}
/// Bearer token auth middleware
async fn auth_middleware(
State(expected_key): State<String>,
headers: HeaderMap,
request: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let token = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(|t| t.trim().to_string());
match token {
Some(t) if t == expected_key => Ok(next.run(request).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}

823
src/tools.rs Normal file
View file

@ -0,0 +1,823 @@
// 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()
}
}
}