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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ cached session, so it also overrides a stale cached timezone.

| Tool | Description |
|------|-------------|
| `search_activities` | Find activities to log as exercise, by name |
| `add_exercise` | Log an exercise with its duration and calories burned |
| `get_macro_targets` | Weekly macro schedule and saved target templates |
| `get_fasting_history` | Fasting history within a date range |
| `get_fasting_stats` | Aggregate fasting statistics |
Expand Down
112 changes: 112 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,115 @@ def get_biometrics(
)
return data

# ------------------------------------------------------------------
# Exercise
# ------------------------------------------------------------------

def get_activities(self) -> list[dict]:
"""List every activity an exercise can be logged as.

The app's activity picker merges two catalogs: the current one from
/api/v2/get_activity_forest (about 200 activities, ids from 1135 up,
grouped into categories) and the older, more detailed one from
/api/v2/all_legacy_activities (about 1100, ids 1-1134, e.g. "Walking
3.0 mph, moderate"). The id ranges don't overlap, and add_exercise
accepts ids from either.

Returns [{"id": int, "name": str, "legacy": bool}, ...].
"""
config = {"config": {"call_version": 1}}
forest = self._request("/api/v2/get_activity_forest", dict(config))
legacy = self._request("/api/v2/all_legacy_activities", dict(config))
activities = [
{"id": a["id"], "name": a["name"], "legacy": False}
for a in (forest.get("activitiesMap") or {}).values()
]
activities += [
{"id": a["id"], "name": a["name"], "legacy": True}
for a in legacy.get("activities") or []
]
return activities

def add_exercise(
self,
activity_id: int,
minutes: float,
calories: float,
day: date | None = None,
diary_group: int = 0,
) -> dict:
"""Log an exercise with a given calorie burn.

The app computes the burn itself from the activity, the minutes and
the user's weight and sends it with calorieOverride false. Here the
caller supplies it and calorieOverride is set, so no activity
intensity data is needed. The server still requires the body weight
(in lb), which is taken from the profile.

Args:
activity_id: Activity ID from get_activities.
minutes: Duration in minutes.
calories: Calories burned (a positive number).
day: Date to log to. Defaults to today.
diary_group: Meal group, as for add_serving (0 = auto).

Uses: POST /api/v2/add_exercise

Returns {"exerciseId": int, "activityId", "minutes", "calories", "day"}.
"""
if not math.isfinite(minutes) or minutes <= 0:
raise ValueError(f"minutes must be positive, got {minutes}")
if not math.isfinite(calories) or calories < 0:
raise ValueError(f"calories must be zero or positive, got {calories}")
if diary_group == 0:
diary_group = _meal_group_for_hour(self.now().hour)

exercise = {
"exerciseId": None,
"type": "Exercise",
"activityId": activity_id,
"activitySpecId": None,
"minutes": minutes,
"calories": -calories, # stored negative: energy out
"calorieOverride": True,
"weight": round(self._profile_weight_kg() / _KG_PER_LB, 1),
"day": self._format_day(day),
"order": (diary_group << 16) | 1,
"name": None,
"meta": None,
"externalId": None,
"source": None,
"userId": self.user_id,
}
payload = {"exercise": exercise, "config": {"call_version": 1}}
data = self._request("/api/v2/add_exercise", payload)
if not data.get("id"):
raise CronometerError(f"Failed to add exercise: {data}")
logger.info(
"Logged exercise %d (%.0f min, %.0f kcal) on %s",
activity_id,
minutes,
calories,
exercise["day"],
)
return {
"exerciseId": data["id"],
"activityId": activity_id,
"minutes": minutes,
"calories": calories,
"day": (day or self.today()).isoformat(),
}

def _profile_weight_kg(self) -> float:
"""The current body weight from the profile's prefs, in kg."""
profile = self._request("/api/v2/get_profile", {"config": {"call_version": 1}})
for pref in profile.get("prefs") or []:
if "weightInKG" in pref:
return float(pref["weightInKG"])
raise CronometerError(
"The profile has no weight; set one in Cronometer before logging exercise"
)


# ======================================================================
# Helpers
Expand Down Expand Up @@ -1991,6 +2100,9 @@ def _translation_id(food: dict) -> int:
return 0


_KG_PER_LB = 0.45359237


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

Expand Down
67 changes: 67 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,73 @@ def get_biometrics(
return _err(e)


@mcp.tool(annotations=_READ_ONLY)
def search_activities(query: str) -> str:
"""Find activities to log with add_exercise, by name.

Searches Cronometer's two activity catalogs: the current one ("General
Walking", "HIIT") and the older, more specific one ("Walking 3.0 mph,
moderate"). Every word of the query must appear in the name.

Args:
query: Activity name or keywords (e.g. "walking", "cycling").
"""
try:
client = _get_client()
words = query.lower().split()
matches = [
{"activity_id": a["id"], "name": a["name"], "legacy": a["legacy"]}
for a in client.get_activities()
if all(w in a["name"].lower() for w in words)
]
return _ok({"query": query, "count": len(matches), "activities": matches})
except Exception as e:
return _err(e)


@mcp.tool(annotations=_WRITE)
def add_exercise(
activity_id: int,
minutes: float,
calories: float,
date: str | None = None,
diary_group: str = "auto",
) -> str:
"""Log an exercise to the Cronometer diary.

Use search_activities to find activity_id. The calorie burn is not
calculated for you: pass it (estimate it from the activity's MET value,
the minutes and the user's weight if the user doesn't know it). The
exercise appears in get_food_log's diary as an "Exercise" entry;
remove_food_entry with its exerciseId deletes it.

Args:
activity_id: Activity ID from search_activities.
minutes: Duration in minutes.
calories: Calories burned, as a positive number.
date: Date as YYYY-MM-DD (defaults to today).
diary_group: Meal slot -- one of "auto", "breakfast", "lunch",
"dinner", "snacks" (default "auto").
"""
try:
groups = {"auto": 0, "breakfast": 1, "lunch": 2, "dinner": 3, "snacks": 4}
group_int = groups.get(diary_group.strip().lower())
if group_int is None:
return _err(
ValueError(
f"Invalid diary_group '{diary_group}'. "
"Must be one of: auto, breakfast, lunch, dinner, snacks."
)
)
client = _get_client()
result = client.add_exercise(
activity_id, minutes, calories, _parse_date(date), group_int
)
return _ok({"exercise": result})
except Exception as e:
return _err(e)


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
Expand Down
76 changes: 76 additions & 0 deletions tests/test_exercise.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""get_activities / add_exercise."""

from __future__ import annotations

from datetime import date

import pytest
from test_client import make_cold_client

from cronometer_api_mcp.client import CronometerError

FOREST = {
"activitiesMap": {
"1342": {"id": 1342, "name": "Treadmill Walking", "calories": 0},
},
"groupsMap": {},
}
LEGACY = {"activities": [{"id": 12, "name": "Walking 3.0 mph", "cals": 1.5}]}
PROFILE = {"prefs": [{"tz": 0}, {"weightInKG": 80.0}]}
ADDED = {"messages": [], "id": 881403934}


def test_get_activities_merges_both_catalogs(tmp_path):
client, _ = make_cold_client(tmp_path, [FOREST, LEGACY])

assert client.get_activities() == [
{"id": 1342, "name": "Treadmill Walking", "legacy": False},
{"id": 12, "name": "Walking 3.0 mph", "legacy": True},
]


def test_add_exercise_sends_override_calories_and_profile_weight(tmp_path):
client, state = make_cold_client(tmp_path, [PROFILE, ADDED])

result = client.add_exercise(1342, 15, 40, date(1900, 1, 2), diary_group=2)

sent = state["payloads"][1]
assert sent["config"] == {"call_version": 1}
ex = sent["exercise"]
assert ex["activityId"] == 1342
assert ex["minutes"] == 15
assert ex["calories"] == -40
assert ex["calorieOverride"] is True
assert ex["weight"] == 176.4 # 80 kg in lb
assert ex["day"] == "1900-1-2"
assert ex["order"] == (2 << 16) | 1
assert ex["exerciseId"] is None
assert result == {
"exerciseId": 881403934,
"activityId": 1342,
"minutes": 15,
"calories": 40,
"day": "1900-01-02",
}


def test_add_exercise_needs_a_profile_weight(tmp_path):
client, state = make_cold_client(tmp_path, [{"prefs": [{"tz": 0}]}, ADDED])

with pytest.raises(CronometerError, match="no weight"):
client.add_exercise(1342, 15, 40, diary_group=1)
assert len(state["payloads"]) == 1


@pytest.mark.parametrize(
("minutes", "calories", "message"),
[(0, 10, "minutes"), (10, -5, "calories"), (float("nan"), 10, "minutes")],
)
def test_add_exercise_validates_before_any_request(
tmp_path, minutes, calories, message
):
client, state = make_cold_client(tmp_path, [PROFILE, ADDED])

with pytest.raises(ValueError, match=message):
client.add_exercise(1342, minutes, calories, diary_group=1)
assert state["payloads"] == []
2 changes: 2 additions & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

EXPECTED_TOOLS = {
"add_custom_food",
"add_exercise",
"add_food_entry",
"add_recipe",
"copy_day",
Expand All @@ -29,6 +30,7 @@
"list_biometrics",
"mark_day_complete",
"remove_food_entry",
"search_activities",
"search_foods",
"update_custom_food",
}
Expand Down