Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ cached session, so it also overrides a stale cached timezone.

| Tool | Description |
|------|-------------|
| `get_macro_targets` | Weekly macro schedule and saved target templates |
| `get_macro_targets` | Macro split (percent of energy), weekly macro schedule and saved target templates |
| `set_macro_split` | Set the protein / carbs / fat split in percent of energy |
| `get_fasting_history` | Fasting history within a date range |
| `get_fasting_stats` | Aggregate fasting statistics |
| `list_biometrics` | List trackable biometric metrics and their units |
Expand Down
56 changes: 56 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,54 @@ def get_macro_target_templates(self) -> dict:
logger.info("Fetched macro target templates")
return data

def get_macro_split(self) -> dict:
"""The macro split (percent of energy) from the profile's prefs.

The app keeps it as three user prefs, macroProtein, macroCarbs and
macroLipids, read back through /api/v2/get_profile. Prefs come as a
list of one-key objects.

Returns {"protein": float, "carbs": float, "fat": float}; a share
that was never set is None.
"""
profile = self._request("/api/v2/get_profile", {"config": {"call_version": 1}})
prefs: dict = {}
for pref in profile.get("prefs") or []:
prefs.update(pref)
return {
name: float(prefs[key]) if prefs.get(key) is not None else None
for name, key in _MACRO_SPLIT_PREFS.items()
}

def set_macro_split(self, protein: float, carbs: float, fat: float) -> dict:
"""Set the macro split, in percent of energy.

Writes the three prefs with /api/v2/set_pref, one call per macro, as
the app does (it sends only the ones that changed; all three are
sent here so the result is exact). The shares must add up to 100.

Returns the split as written.
"""
split = {"protein": protein, "carbs": carbs, "fat": fat}
for name, value in split.items():
if not math.isfinite(value) or not 0 <= value <= 100:
raise ValueError(f"{name} must be between 0 and 100, got {value}")
if abs(sum(split.values()) - 100) > 0.01:
raise ValueError(
f"protein, carbs and fat must add up to 100, got {sum(split.values())}"
)
for name, key in _MACRO_SPLIT_PREFS.items():
payload = {
"key": key,
"val": str(float(split[name])),
"config": {"call_version": 1},
}
data = self._request("/api/v2/set_pref", payload)
if data.get("result") != "SUCCESS":
raise CronometerError(f"Failed to set {key}: {data}")
logger.info("Set macro split to %s", split)
return split

# ------------------------------------------------------------------
# Fasting
# ------------------------------------------------------------------
Expand Down Expand Up @@ -1991,6 +2039,14 @@ def _translation_id(food: dict) -> int:
return 0


# Profile prefs holding the macro split, in percent of energy.
_MACRO_SPLIT_PREFS = {
"protein": "macroProtein",
"carbs": "macroCarbs",
"fat": "macroLipids",
}


def _meal_group_for_hour(hour: int) -> int:
"""Map hour of day to a Cronometer diary meal group.

Expand Down
26 changes: 24 additions & 2 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,15 +895,17 @@ def import_recipe(ingredients: str, name: str | None = None) -> str:
def get_macro_targets() -> str:
"""Get current macro targets including weekly schedule and templates.

Returns the weekly macro schedule (which template applies to each day)
and all saved macro target templates with their values.
Returns the macro split (percent of energy for protein, carbs and fat),
the weekly macro schedule (which template applies to each day) and all
saved macro target templates with their values.
"""
try:
client = _get_client()
schedules = client.get_macro_schedules()
templates = client.get_macro_target_templates()
return _ok(
{
"macro_split": client.get_macro_split(),
"schedules": schedules,
"templates": templates,
}
Expand All @@ -912,6 +914,26 @@ def get_macro_targets() -> str:
return _err(e)


@mcp.tool(annotations=_WRITE_IDEMPOTENT)
def set_macro_split(protein: float, carbs: float, fat: float) -> str:
"""Set how the energy target is split between protein, carbs and fat.

This changes the user's real targets: confirm the numbers with the user
first, and read the current split with get_macro_targets so it can be
put back. Values are percent of energy and must add up to 100.

Args:
protein: Protein share in percent (e.g. 30).
carbs: Carbohydrate share in percent (e.g. 40).
fat: Fat share in percent (e.g. 30).
"""
try:
client = _get_client()
return _ok({"macro_split": client.set_macro_split(protein, carbs, fat)})
except Exception as e:
return _err(e)


# ------------------------------------------------------------------
# Fasting
# ------------------------------------------------------------------
Expand Down
64 changes: 64 additions & 0 deletions tests/test_macro_split.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""get_macro_split / set_macro_split: the macroProtein, macroCarbs and
macroLipids profile prefs, written with /api/v2/set_pref."""

from __future__ import annotations

import pytest
from test_client import make_cold_client

from cronometer_api_mcp.client import CronometerError

SET_OK = {"result": "SUCCESS", "messages": []}


def test_get_macro_split_reads_profile_prefs(tmp_path):
profile = {
"prefs": [
{"macroLipids": 30.0},
{"tz": 0},
{"macroProtein": "30.0"},
{"macroCarbs": "40.0"},
]
}
client, _ = make_cold_client(tmp_path, [profile])

assert client.get_macro_split() == {"protein": 30.0, "carbs": 40.0, "fat": 30.0}


def test_get_macro_split_unset_share_is_none(tmp_path):
client, _ = make_cold_client(tmp_path, [{"prefs": [{"macroProtein": "25"}]}])

assert client.get_macro_split() == {"protein": 25.0, "carbs": None, "fat": None}


def test_set_macro_split_writes_all_three_prefs(tmp_path):
client, state = make_cold_client(tmp_path, [SET_OK])

result = client.set_macro_split(25, 45, 30)

assert [(p["key"], p["val"]) for p in state["payloads"]] == [
("macroProtein", "25.0"),
("macroCarbs", "45.0"),
("macroLipids", "30.0"),
]
assert all(p["config"] == {"call_version": 1} for p in state["payloads"])
assert result == {"protein": 25, "carbs": 45, "fat": 30}


@pytest.mark.parametrize(
("split", "message"),
[((30, 30, 30), "add up to 100"), ((-5, 70, 35), "between 0 and 100")],
)
def test_set_macro_split_validates_before_any_request(tmp_path, split, message):
client, state = make_cold_client(tmp_path, [SET_OK])

with pytest.raises(ValueError, match=message):
client.set_macro_split(*split)
assert state["payloads"] == []


def test_set_macro_split_raises_on_unexpected_response(tmp_path):
client, _ = make_cold_client(tmp_path, [{"messages": []}])

with pytest.raises(CronometerError, match="macroProtein"):
client.set_macro_split(30, 40, 30)
1 change: 1 addition & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"mark_day_complete",
"remove_food_entry",
"search_foods",
"set_macro_split",
"update_custom_food",
}

Expand Down