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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
69 changes: 69 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
58 changes: 49 additions & 9 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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],
Expand Down
103 changes: 103 additions & 0 deletions tests/test_diary_edit.py
Original file line number Diff line number Diff line change
@@ -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"] == []
1 change: 1 addition & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"remove_food_entry",
"search_foods",
"update_custom_food",
"update_food_entry",
}


Expand Down