mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
The calendar already said which individual days went over budget, but not whether the week as a whole did. A run of small overages reads as four harmless yellow squares; averaged, it is the thing that actually moves the needle. Three rules are load-bearing, and are documented in CLAUDE.md because getting any of them wrong silently changes what "under budget" means: - The denominator is logged days, not elapsed days. An unlogged day is not a zero-kcal day, and averaging gaps in as zeros would make a badly-tracked week look like the best week of the year. - The yardstick is the mean of the per-day budgets over those same days, resolved through BudgetSchedule -- never today's budget, which would retroactively reclassify past weeks on every budget edit. - Today is excluded. A half-logged today drags the mean far enough to flip the band, so every period ends at yesterday. Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by import rather than by copy, so a period and its days can never disagree about what "over" means; that constant loses its underscore to say so. The MCP get_averages tool returns the average and the band but drops the average budget, keeping the module's existing invariant that the budget number never leaves this machine. The CLI, the gate and the phone all show both, since it is hidden from the network, not from the user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""MCP (Model Context Protocol) server for diet_guard.
|
|
|
|
Exposes diet_guard's read surface and one *gated* write action as typed MCP
|
|
tools, so an MCP client (Claude Code and its subagents) can query today's
|
|
intake and -- with explicit confirmation -- log a meal without shelling out to
|
|
the interactive CLI.
|
|
|
|
Run via the dedicated venv that has the ``mcp`` extra installed::
|
|
|
|
~/.venvs/diet-guard-mcp/bin/python -m diet_guard._mcp
|
|
|
|
(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
|
|
|
|
Safety invariants (do not break when adding tools):
|
|
* **stdout is the JSON-RPC channel.** This module and every function a tool
|
|
calls must never write to stdout. All logging is routed to STDERR below,
|
|
and tools call only stdout-free leaf helpers (never the ``_cmd_*`` /
|
|
``main`` CLI handlers, which write to stdout, read stdin, and ``sys.exit``).
|
|
* **The daily budget number never leaves via this interface.** No tool
|
|
returns the raw budget, the remaining-budget number, the stored body
|
|
weight, the protein target, or the ``.budget`` file: an automated caller
|
|
should not be handed the exact number to reason about or help game,
|
|
even though the number itself is freely visible to the human via the
|
|
CLI/GUI and the phone app. Read tools expose only today's *consumed*
|
|
kcal / macros and the qualitative :func:`consumption_band` string.
|
|
* **No secret ever leaves.** There is no tool that reads the shared HMAC key,
|
|
the sync token, or any file under ``/etc``.
|
|
* **Writes are gated.** The write tool defaults to a dry-run preview and
|
|
mutates only when ``confirm=True``. It must never be added to a permission
|
|
allowlist (a subagent could then bypass the human), and it never raises --
|
|
a failed write degrades to ``{"ok": false, ...}``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from typing import Any
|
|
|
|
from mcp.server import MCPServer
|
|
from mcp.types import ToolAnnotations
|
|
from pydantic import BaseModel
|
|
|
|
from diet_guard._averages import (
|
|
PeriodAverage,
|
|
band_label,
|
|
monthly_average,
|
|
weekly_average,
|
|
)
|
|
from diet_guard._budget import BudgetError, current_schedule, daily_budget
|
|
from diet_guard._gate import due_slots
|
|
from diet_guard._resolve import ManualMacros, resolve_nutrition
|
|
from diet_guard._slots import current_slot, day_slots, slot_for_log, slot_label
|
|
from diet_guard._state import (
|
|
consumption_band,
|
|
load_log,
|
|
logged_slots_today,
|
|
now_local,
|
|
today_entries,
|
|
today_total_kcal,
|
|
today_total_macros,
|
|
)
|
|
from diet_guard._state import (
|
|
log_meal as record_meal,
|
|
)
|
|
|
|
# Log to STDERR only -- STDOUT carries the MCP JSON-RPC protocol frames, so a
|
|
# single stray stdout write would corrupt the stream and kill the session.
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
stream=sys.stderr,
|
|
format="%(asctime)s [%(levelname)s] diet-guard-mcp: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
mcp = MCPServer("diet-guard")
|
|
|
|
# Machine-readable versions of what the docstrings already say, so a client
|
|
# can decide what to run unattended. The day a "read" tool starts writing,
|
|
# these annotations are what has to change with it.
|
|
_READS_ONLY = ToolAnnotations(
|
|
read_only_hint=True,
|
|
idempotent_hint=True,
|
|
open_world_hint=False,
|
|
)
|
|
# log_meal is the one tool that writes. It appends and never rewrites or
|
|
# deletes, so it is not destructive; it is not idempotent either, because a
|
|
# second confirmed call logs a second meal. It reaches Open Food Facts when
|
|
# macros are not supplied, which is exactly what open_world means.
|
|
_APPENDS = ToolAnnotations(
|
|
read_only_hint=False,
|
|
destructive_hint=False,
|
|
idempotent_hint=False,
|
|
open_world_hint=True,
|
|
)
|
|
|
|
|
|
def _entry_view(entry: dict[str, object]) -> dict[str, Any]:
|
|
"""Project a stored log entry to a safe, JSON-friendly summary.
|
|
|
|
Deliberately drops the ``hmac`` field and any unknown keys: the signature
|
|
is internal integrity metadata, not something a client needs.
|
|
|
|
Args:
|
|
entry: A stored food-log entry.
|
|
|
|
Returns:
|
|
A flat dict of the display-relevant fields.
|
|
"""
|
|
return {
|
|
"time": entry.get("time"),
|
|
"desc": entry.get("desc"),
|
|
"kcal": entry.get("kcal"),
|
|
"protein_g": entry.get("protein_g"),
|
|
"carbs_g": entry.get("carbs_g"),
|
|
"fat_g": entry.get("fat_g"),
|
|
"grams": entry.get("grams"),
|
|
"source": entry.get("source"),
|
|
"slot": entry.get("slot"),
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Read tools (consumed intake + qualitative band only; NEVER the budget number)
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@mcp.tool(title="Today's intake status", annotations=_READS_ONLY)
|
|
def get_status() -> dict[str, Any]:
|
|
"""Return today's intake status without ever revealing the daily budget.
|
|
|
|
Reports the calories and macros *consumed* so far, the qualitative
|
|
:func:`consumption_band` (``"on track"`` / ``"approaching limit"`` /
|
|
``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the
|
|
meal-slot picture (which slots are due, which are already logged, and the
|
|
current slot). The raw budget number is intentionally withheld -- only the
|
|
band is exposed, mirroring how the CLI status shows a label to an automated
|
|
caller rather than the anchor number.
|
|
"""
|
|
try:
|
|
band: str | None = consumption_band()
|
|
except BudgetError:
|
|
# No budget set (or a corrupt file): surface the absence, not a number.
|
|
band = None
|
|
return {
|
|
"consumed_kcal": today_total_kcal(),
|
|
"consumed_macros_g": _macros_dict(today_total_macros()),
|
|
"consumption_band": band,
|
|
"budget_initialized": band is not None,
|
|
"due_slots": [slot_label(slot) for slot in due_slots()],
|
|
"logged_slots": sorted(logged_slots_today()),
|
|
"current_slot": current_slot(now_local()),
|
|
}
|
|
|
|
|
|
@mcp.tool(title="Meals logged today", annotations=_READS_ONLY)
|
|
def list_today() -> dict[str, Any]:
|
|
"""List today's logged meals (valid entries only), newest last.
|
|
|
|
Returns the per-entry description, calories, macros, portion, source, and
|
|
slot -- the same data the CLI ``status`` listing renders, minus the internal
|
|
HMAC signature.
|
|
"""
|
|
entries = today_entries()
|
|
return {
|
|
"count": len(entries),
|
|
"entries": [_entry_view(entry) for entry in entries],
|
|
}
|
|
|
|
|
|
def _period_view(period: PeriodAverage) -> dict[str, Any]:
|
|
"""Render one period for the wire, WITHOUT its average budget.
|
|
|
|
``avg_budget`` is dropped on purpose: it is the daily budget by another
|
|
name, and the module invariant is that the number never leaves via this
|
|
interface. What remains -- the mean intake plus the qualitative band -- is
|
|
the same trade ``get_status`` already makes (a band plus a consumed number
|
|
bounds the budget loosely; the exact figure stays on the machine).
|
|
"""
|
|
return {
|
|
"start": period.start,
|
|
"end": period.end,
|
|
"logged_days": period.logged_days,
|
|
"elapsed_days": period.elapsed_days,
|
|
"avg_kcal": period.avg_kcal,
|
|
"band": None if period.band is None else period.band.value,
|
|
"band_label": band_label(period.band),
|
|
}
|
|
|
|
|
|
@mcp.tool(title="Weekly and monthly average intake", annotations=_READS_ONLY)
|
|
def get_averages() -> dict[str, Any]:
|
|
"""Return average kcal/day for this and last week, and this and last month.
|
|
|
|
Each period's average is over its *logged* days only and is classified
|
|
``"under"`` / ``"slightly_over"`` / ``"very_over"`` against the mean of the
|
|
budgets that applied on those same days -- never against today's budget, so
|
|
a budget edit does not retroactively reclassify past weeks.
|
|
|
|
Every period ends at **yesterday**: today is still being logged, and a
|
|
half-logged day would drag the mean far enough to flip the band. A period
|
|
with no finished logged day yet reports ``avg_kcal: null`` rather than a
|
|
flattering zero.
|
|
|
|
The per-period average budget is deliberately not returned (see the module
|
|
docstring's budget invariant); only the intake and the band are.
|
|
"""
|
|
try:
|
|
schedule = current_schedule(default=daily_budget())
|
|
except BudgetError:
|
|
# No budget set, or a corrupt file: no yardstick, so no bands.
|
|
return {"budget_initialized": False, "periods": {}}
|
|
log = load_log()
|
|
periods = {
|
|
"this_week": weekly_average(log, schedule=schedule, weeks_ago=0),
|
|
"last_week": weekly_average(log, schedule=schedule, weeks_ago=1),
|
|
"this_month": monthly_average(log, schedule=schedule, months_ago=0),
|
|
"last_month": monthly_average(log, schedule=schedule, months_ago=1),
|
|
}
|
|
return {
|
|
"budget_initialized": True,
|
|
"excludes_today": True,
|
|
"periods": {name: _period_view(period) for name, period in periods.items()},
|
|
}
|
|
|
|
|
|
@mcp.tool(title="Meal slots for the day", annotations=_READS_ONLY)
|
|
def get_slots() -> dict[str, Any]:
|
|
"""Return the day's fixed meal slots and which one is current.
|
|
|
|
Pure schedule information (08:00 / 12:00 / 16:00 / 20:00 by default) with no
|
|
budget or intake data attached.
|
|
"""
|
|
return {
|
|
"day_slots": [
|
|
{"hour": slot, "label": slot_label(slot)} for slot in day_slots()
|
|
],
|
|
"current_slot": current_slot(now_local()),
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Gated write tool (preview unless confirm=True; NEVER allowlist this)
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class Macros(BaseModel):
|
|
"""Manually-entered nutrition for one meal.
|
|
|
|
Bundled into a single parameter so ``log_meal`` stays under the arg-count
|
|
limits without any lint suppression (mirrors the ``_ManualMacros`` grouping
|
|
the CLI uses for the same reason). ``kcal`` is required; when this is
|
|
supplied to ``log_meal``, the food-bank / Open Food Facts lookups are
|
|
skipped and the meal is logged exactly as given (fully offline).
|
|
"""
|
|
|
|
kcal: float
|
|
protein: float = 0.0
|
|
carbs: float = 0.0
|
|
fat: float = 0.0
|
|
|
|
|
|
@mcp.tool(title="Log a meal (gated write)", annotations=_APPENDS)
|
|
def log_meal(
|
|
description: str,
|
|
grams: float | None = None,
|
|
macros: Macros | None = None,
|
|
slot: int | None = None,
|
|
*,
|
|
confirm: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Resolve and (on confirm) log a meal to today's food log (gated write).
|
|
|
|
With ``confirm=False`` (the default) this performs **no** mutation: it
|
|
resolves the nutrition and reports the slot the entry would satisfy, so the
|
|
caller can review before applying. Call again with ``confirm=True`` to
|
|
actually append the entry. Nutrition is resolved exactly as the CLI's
|
|
``ate`` does -- manual ``macros`` first (when given), then the local food
|
|
bank / staple table, then Open Food Facts -- so passing ``macros`` keeps the
|
|
write fully offline and deterministic.
|
|
|
|
The write degrades gracefully: if the food log cannot be written it returns
|
|
``{"ok": false, ...}`` rather than raising, so the stdio server survives.
|
|
A missing shared HMAC key is not an error -- :func:`record_meal` then stores
|
|
the entry unsigned (and it is still accepted on read on a keyless system).
|
|
|
|
Args:
|
|
description: Free-text meal description, e.g. ``"big mac"``.
|
|
grams: Portion size in grams (rescales every nutrition source).
|
|
macros: Manually-entered nutrition (``kcal`` plus optional
|
|
protein/carbs/fat). When given, lookups are skipped and the meal is
|
|
logged exactly as specified.
|
|
slot: The meal-slot hour to satisfy; defaults to the current elapsed
|
|
slot (may be ``None`` early in the day, logging calories only).
|
|
confirm: Set ``True`` to actually append the entry; otherwise preview.
|
|
|
|
Returns:
|
|
A preview or applied result dict; ``{"ok": false, "reason": ...}`` when
|
|
the food cannot be resolved or the log cannot be written.
|
|
"""
|
|
manual_macros = (
|
|
ManualMacros(
|
|
kcal=macros.kcal,
|
|
protein=macros.protein,
|
|
carbs=macros.carbs,
|
|
fat=macros.fat,
|
|
)
|
|
if macros is not None
|
|
else None
|
|
)
|
|
nutrition = resolve_nutrition(description, grams=grams, manual_macros=manual_macros)
|
|
if nutrition is None:
|
|
return {
|
|
"ok": False,
|
|
"reason": (
|
|
f'could not resolve "{description}" from the food bank, staples, '
|
|
"or Open Food Facts. Pass kcal=<number> to log it manually."
|
|
),
|
|
}
|
|
target_slot = slot if slot is not None else slot_for_log(now_local())
|
|
resolved = {
|
|
"kcal": nutrition.kcal,
|
|
"protein_g": nutrition.protein_g,
|
|
"carbs_g": nutrition.carbs_g,
|
|
"fat_g": nutrition.fat_g,
|
|
"grams": nutrition.grams,
|
|
"source": nutrition.source,
|
|
}
|
|
if not confirm:
|
|
return {
|
|
"ok": True,
|
|
"preview": True,
|
|
"action": "log_meal",
|
|
"description": description,
|
|
"resolved": resolved,
|
|
"target_slot": target_slot,
|
|
"confirm_required": True,
|
|
}
|
|
try:
|
|
entry = record_meal(description, nutrition, target_slot)
|
|
except OSError as exc: # never crash the stdio server on a write failure
|
|
logger.warning("log_meal write failed: %s", exc)
|
|
return {"ok": False, "reason": "could not write the food log."}
|
|
logger.info("log_meal applied: %s (%g kcal)", description, nutrition.kcal)
|
|
return {
|
|
"ok": True,
|
|
"applied": True,
|
|
"action": "log_meal",
|
|
"description": description,
|
|
"logged": resolved,
|
|
"target_slot": target_slot,
|
|
"signed": "hmac" in entry,
|
|
}
|
|
|
|
|
|
def _macros_dict(macros: tuple[float, float, float]) -> dict[str, float]:
|
|
"""Return a ``(protein, carbs, fat)`` triple as a labelled dict."""
|
|
protein, carbs, fat = macros
|
|
return {"protein": protein, "carbs": carbs, "fat": fat}
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs)."""
|
|
logger.info("Starting diet-guard MCP server (python=%s)", sys.executable)
|
|
mcp.run() # pragma: no cover
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|