From 2a4b9641e06641d2361bf30fc5f6d6886f5a10d2 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:30:01 +0200 Subject: [PATCH 1/2] Add search_activities and add_exercise tools The app logs exercise with /api/v2/add_exercise. It computes the calorie burn itself; here the caller passes it and calorieOverride is set, so no activity intensity data is needed. The server requires the body weight (lb), which comes from the profile. Activities come from the two catalogs the app's picker merges (get_activity_forest and all_legacy_activities). Verified against the live API with ids from both catalogs. Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- README.md | 2 + src/cronometer_api_mcp/client.py | 112 +++++++++++++++++++++++++++++++ src/cronometer_api_mcp/server.py | 67 ++++++++++++++++++ tests/test_exercise.py | 76 +++++++++++++++++++++ tests/test_server_import.py | 2 + 5 files changed, 259 insertions(+) create mode 100644 tests/test_exercise.py diff --git a/README.md b/README.md index 1f73d5e..cbced34 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..61ada08 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -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 @@ -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. diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..37b6fe5 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -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 # ------------------------------------------------------------------ diff --git a/tests/test_exercise.py b/tests/test_exercise.py new file mode 100644 index 0000000..5607d00 --- /dev/null +++ b/tests/test_exercise.py @@ -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": 120}, {"weightInKG": 75.4}]} +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"] == 166.2 # 75.4 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": 120}]}, 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"] == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..688dd7e 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -13,6 +13,7 @@ EXPECTED_TOOLS = { "add_custom_food", + "add_exercise", "add_food_entry", "add_recipe", "copy_day", @@ -29,6 +30,7 @@ "list_biometrics", "mark_day_complete", "remove_food_entry", + "search_activities", "search_foods", "update_custom_food", } From 94e2884888e429a93cb7f0688e3347e02d640a61 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:37:52 +0200 Subject: [PATCH 2/2] tests: use neutral example values Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- tests/test_exercise.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_exercise.py b/tests/test_exercise.py index 5607d00..e8662cc 100644 --- a/tests/test_exercise.py +++ b/tests/test_exercise.py @@ -16,7 +16,7 @@ "groupsMap": {}, } LEGACY = {"activities": [{"id": 12, "name": "Walking 3.0 mph", "cals": 1.5}]} -PROFILE = {"prefs": [{"tz": 120}, {"weightInKG": 75.4}]} +PROFILE = {"prefs": [{"tz": 0}, {"weightInKG": 80.0}]} ADDED = {"messages": [], "id": 881403934} @@ -41,7 +41,7 @@ def test_add_exercise_sends_override_calories_and_profile_weight(tmp_path): assert ex["minutes"] == 15 assert ex["calories"] == -40 assert ex["calorieOverride"] is True - assert ex["weight"] == 166.2 # 75.4 kg in lb + 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 @@ -55,7 +55,7 @@ def test_add_exercise_sends_override_calories_and_profile_weight(tmp_path): def test_add_exercise_needs_a_profile_weight(tmp_path): - client, state = make_cold_client(tmp_path, [{"prefs": [{"tz": 120}]}, ADDED]) + 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)