From f0c318dd48f2666fad755dbfec5282f809b96e77 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:24:44 +0200 Subject: [PATCH] Add update_food_entry tool to edit or move a logged entry The app changes a serving's amount with /api/v2/edit_serving, re-sending the whole serving. The meal group lives in the high bits of `order`, and edit_serving accepts a new order too, so one call changes the amount, the measure and the meal. Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- README.md | 1 + src/cronometer_api_mcp/client.py | 69 +++++++++++++++++++++ src/cronometer_api_mcp/server.py | 58 ++++++++++++++--- tests/test_diary_edit.py | 103 +++++++++++++++++++++++++++++++ tests/test_server_import.py | 1 + 5 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 tests/test_diary_edit.py diff --git a/README.md b/README.md index 1f73d5e..0e9056d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ cached session, so it also overrides a stale cached timezone. | Tool | Description | |------|-------------| | `add_food_entry` | Log a food serving to the diary | +| `update_food_entry` | Change a logged entry's amount or measure, or move it to another meal | | `remove_food_entry` | Remove one or more diary entries | | `add_custom_food` | Create a custom food with specified nutrition and optional barcodes | | `update_custom_food` | Edit a custom food in place: name, nutrition, serving size, or barcodes | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..f0fe70a 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1427,6 +1427,75 @@ def add_serving( ) return data + def update_serving( + self, + serving_id: str | int, + day: date | None = None, + *, + grams: float | None = None, + measure_id: int | None = None, + diary_group: int | None = None, + ) -> dict: + """Change the amount, measure or meal group of a logged serving. + + Fetches the day's diary, takes the serving as stored and sends it back + to /api/v2/edit_serving with the changed fields. The server keeps the + serving id. The meal group lives in the high bits of `order` (see + add_serving); a moved serving goes to the end of its new group. The + app moves entries with /api/v2/update_order, but edit_serving accepts + a new `order` as well (verified against the live API), so one call + covers both. + + Args: + serving_id: The servingId from get_diary. + day: The day the serving is logged on. Defaults to today. + grams: New weight in grams. + measure_id: New display measure (grams stays the amount). + diary_group: New meal group, 1 = Breakfast, 2 = Lunch, + 3 = Dinner, 4 = Snacks. + + Returns the updated serving as sent. + """ + if grams is None and measure_id is None and diary_group is None: + raise ValueError("update_serving needs grams, measure_id or diary_group") + if grams is not None and grams <= 0: + raise ValueError(f"grams must be positive, got {grams}") + if diary_group is not None and diary_group not in (1, 2, 3, 4): + raise ValueError(f"diary_group must be 1-4, got {diary_group}") + + entries = self.get_diary(day).get("diary", []) + serving = next( + (e for e in entries if str(e.get("servingId")) == str(serving_id)), + None, + ) + if serving is None: + raise CronometerError( + f"No serving {serving_id} in the diary for {self._format_day(day)}" + ) + + serving = dict(serving) + if grams is not None: + serving["grams"] = grams + if measure_id is not None: + serving["measureId"] = measure_id + if diary_group is not None and serving.get("order", 0) >> 16 != diary_group: + last = max( + ( + e.get("order", 0) & 0xFFFF + for e in entries + if e.get("order", 0) >> 16 == diary_group + ), + default=0, + ) + serving["order"] = (diary_group << 16) | (last + 1) + + payload = {"serving": serving, "config": {"call_version": 2}} + data = self._request("/api/v2/edit_serving", payload) + if str(data.get("id")) != str(serving["servingId"]): + raise CronometerError(f"Failed to update serving {serving_id}: {data}") + logger.info("Updated serving %s on %s", serving_id, serving.get("day")) + return serving + # ------------------------------------------------------------------ # Diary: get diary entries # ------------------------------------------------------------------ diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..876e66a 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -258,6 +258,9 @@ def _food_log_for_day(client: CronometerClient, day: date) -> dict: # ------------------------------------------------------------------ +_DIARY_GROUPS = {"auto": 0, "breakfast": 1, "lunch": 2, "dinner": 3, "snacks": 4} + + @mcp.tool(annotations=_WRITE) def add_food_entry( food_id: int, @@ -282,15 +285,7 @@ def add_food_entry( "dinner", "snacks" (case-insensitive, default "auto"). """ try: - group_map = { - "auto": 0, - "breakfast": 1, - "lunch": 2, - "dinner": 3, - "snacks": 4, - } - group_key = diary_group.strip().lower() - group_int = group_map.get(group_key) + group_int = _DIARY_GROUPS.get(diary_group.strip().lower()) if group_int is None: return _err( ValueError( @@ -319,6 +314,51 @@ def add_food_entry( return _err(e) +@mcp.tool(annotations=_WRITE_IDEMPOTENT) +def update_food_entry( + entry_id: str, + date: str | None = None, + grams: float | None = None, + measure_id: int | None = None, + diary_group: str | None = None, +) -> str: + """Change a logged food entry: its amount, its measure, or its meal. + + Use get_food_log to find the entry ID. Pass only what should change. + + Args: + entry_id: Serving/entry ID from get_food_log. + date: Date the entry is logged on as YYYY-MM-DD (defaults to today). + grams: New weight of the serving in grams. + measure_id: New measure/unit ID from get_food_details (the amount is + still given in grams). + diary_group: Move to another meal -- one of "breakfast", "lunch", + "dinner", "snacks" (case-insensitive). + """ + try: + group_int = None + if diary_group is not None: + group_int = _DIARY_GROUPS.get(diary_group.strip().lower()) + if not group_int: + return _err( + ValueError( + f"Invalid diary_group '{diary_group}'. " + "Must be one of: breakfast, lunch, dinner, snacks." + ) + ) + client = _get_client() + entry = client.update_serving( + entry_id, + _parse_date(date), + grams=grams, + measure_id=measure_id, + diary_group=group_int, + ) + return _ok({"entry": entry}) + except Exception as e: + return _err(e) + + @mcp.tool(annotations=_DESTRUCTIVE) def remove_food_entry( entry_ids: list[str], diff --git a/tests/test_diary_edit.py b/tests/test_diary_edit.py new file mode 100644 index 0000000..6203417 --- /dev/null +++ b/tests/test_diary_edit.py @@ -0,0 +1,103 @@ +"""update_serving: change a logged serving's amount, measure or meal group. + +The serving is read back from get_diary and re-sent whole to +/api/v2/edit_serving. The meal group is the high 16 bits of `order`. +""" + +from __future__ import annotations + +from datetime import date + +import pytest +from test_client import make_cold_client + +from cronometer_api_mcp.client import CronometerError + +DAY = date(1900, 1, 2) +SERVING_ID = 5312202908 + + +def serving(**overrides) -> dict: + """A get_diary entry shaped like the real one.""" + entry = { + "servingId": SERVING_ID, + "type": "Serving", + "foodId": 5442, + "measureId": 14990, + "translationId": 0, + "grams": 100, + "day": "1900-01-02", + "time": "11:23:50", + "offset": 0, + "userId": 42, + "order": (1 << 16) | 1, # breakfast, first + } + entry.update(overrides) + return entry + + +def diary(*entries) -> dict: + return {"diary": list(entries), "summary": {}, "messages": []} + + +EDITED = {"messages": [], "id": SERVING_ID} + + +def test_update_serving_changes_grams_and_keeps_the_rest(tmp_path): + client, state = make_cold_client(tmp_path, [diary(serving()), EDITED]) + + result = client.update_serving(SERVING_ID, DAY, grams=150) + + sent = state["payloads"][1] + assert sent["config"] == {"call_version": 2} + assert sent["serving"] == serving(grams=150) + assert result == serving(grams=150) + + +def test_update_serving_moves_to_end_of_new_group(tmp_path): + lunch = [ + serving(servingId=1, order=(2 << 16) | 1), + serving(servingId=2, order=(2 << 16) | 4), + ] + client, state = make_cold_client(tmp_path, [diary(serving(), *lunch), EDITED]) + + client.update_serving(str(SERVING_ID), DAY, diary_group=2) + + assert state["payloads"][1]["serving"]["order"] == (2 << 16) | 5 + + +def test_update_serving_same_group_keeps_position(tmp_path): + entry = serving(order=(1 << 16) | 3) + client, state = make_cold_client(tmp_path, [diary(entry), EDITED]) + + client.update_serving(SERVING_ID, DAY, diary_group=1, measure_id=7) + + sent = state["payloads"][1]["serving"] + assert sent["order"] == (1 << 16) | 3 + assert sent["measureId"] == 7 + + +def test_update_serving_unknown_id_raises_without_editing(tmp_path): + client, state = make_cold_client(tmp_path, [diary(serving()), EDITED]) + + with pytest.raises(CronometerError, match="No serving 999"): + client.update_serving(999, DAY, grams=10) + + assert len(state["payloads"]) == 1 + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "needs grams"), + ({"grams": 0}, "positive"), + ({"diary_group": 5}, "1-4"), + ], +) +def test_update_serving_validates_before_any_request(tmp_path, kwargs, message): + client, state = make_cold_client(tmp_path, [diary(serving()), EDITED]) + + with pytest.raises(ValueError, match=message): + client.update_serving(SERVING_ID, DAY, **kwargs) + + assert state["payloads"] == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..53d9ec9 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -31,6 +31,7 @@ "remove_food_entry", "search_foods", "update_custom_food", + "update_food_entry", }