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:
parent
7990757bb3
commit
244e141053
9 changed files with 4360 additions and 5 deletions
|
|
@ -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
10
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
|
/target
|
||||||
# Beads / Dolt files (added by bd init)
|
|
||||||
.dolt/
|
|
||||||
*.db
|
*.db
|
||||||
.beads-credential-key
|
*.db-journal
|
||||||
.beads/proxieddb/
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
2874
Cargo.lock
generated
Normal file
2874
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
49
Cargo.toml
Normal file
49
Cargo.toml
Normal 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
241
src/api.rs
Normal 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(¶ms)
|
||||||
|
.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(¶ms).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
74
src/config.rs
Normal 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
183
src/db.rs
Normal 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
110
src/main.rs
Normal 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
823
src/tools.rs
Normal 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, ¶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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue