From 59860dbef8f07a812615e6c2fd7cfaca74b34120 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:29:04 +0200 Subject: [PATCH 1/2] Add add_biometric tool to record weight, body fat and other readings The app records biometrics with /api/v2/add_biometric (biometricId 0 for a new reading, metric and unit ids from get_metrics). Verified against the live API: the value is stored in the unit given, several readings of one metric can share a day, and a reading on an earlier day doesn't change the profile weight. Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- README.md | 1 + src/cronometer_api_mcp/client.py | 59 ++++++++++++++++++++++++++++++++ src/cronometer_api_mcp/server.py | 29 ++++++++++++++++ tests/test_biometrics_write.py | 53 ++++++++++++++++++++++++++++ tests/test_server_import.py | 1 + 5 files changed, 143 insertions(+) create mode 100644 tests/test_biometrics_write.py diff --git a/README.md b/README.md index 1f73d5e..0ce77b5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ cached session, so it also overrides a stale cached timezone. | `get_fasting_stats` | Aggregate fasting statistics | | `list_biometrics` | List trackable biometric metrics and their units | | `get_biometrics` | Biometric time series (e.g. weight, body fat) within a date range | +| `add_biometric` | Record a biometric reading such as a weight or body fat | All date parameters use `YYYY-MM-DD` format and default to today when omitted. diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..3eac3c1 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1960,6 +1960,65 @@ def get_biometrics( ) return data + def add_biometric( + self, + metric_id: int, + unit_id: int, + amount: float, + day: date | None = None, + ) -> dict: + """Record a biometric value (e.g. a weight or body fat reading). + + Args: + metric_id: Metric ID from get_metrics (e.g. 1 for Weight, + 8 for Body Fat). + unit_id: Unit ID from that metric's units (e.g. 1 for kg, 2 for + lbs). The value is stored in this unit. + amount: The reading. + day: Date of the reading. Defaults to today. + + Several readings of one metric can share a day; get_biometrics then + reports their average. A reading on an earlier day doesn't change + the profile's current weight. + + Uses: POST /api/v2/add_biometric + + Returns {"biometricId": int, "metricId", "unitId", "amount", "day"}. + """ + if not math.isfinite(amount) or amount < 0: + raise ValueError(f"amount must be a non-negative number, got {amount}") + biometric = { + "biometricId": 0, + "type": "Biometric", + "metricId": metric_id, + "unitId": unit_id, + "amount": amount, + "day": self._format_day(day), + "order": 1, + "meta": None, + "externalId": None, + "source": None, + "userId": self.user_id, + } + payload = {"biometric": biometric, "config": {"call_version": 1}} + data = self._request("/api/v2/add_biometric", payload) + if not data.get("id"): + raise CronometerError(f"Failed to add biometric: {data}") + logger.info( + "Recorded metric %d = %s (unit %d) on %s", + metric_id, + amount, + unit_id, + biometric["day"], + ) + return { + "biometricId": data["id"], + "metricId": metric_id, + "unitId": unit_id, + "amount": amount, + "day": (day or self.today()).isoformat(), + } + # ====================================================================== # Helpers diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..2a5dde7 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -1043,6 +1043,35 @@ def get_biometrics( return _err(e) +@mcp.tool(annotations=_WRITE) +def add_biometric( + metric_id: int, + unit_id: int, + amount: float, + date: str | None = None, +) -> str: + """Record a biometric reading such as a weight or body fat. + + Use list_biometrics for metric_id and unit_id (e.g. Weight is metric_id 1 + with unit_id 1 for kg; Body Fat is metric_id 8 with unit_id 13 for %). + The reading appears in get_food_log's diary as a "Biometric" entry; + remove_food_entry with its biometricId deletes it. + + Args: + metric_id: Numeric metric ID from list_biometrics. + unit_id: Numeric unit ID from the metric's units; the amount is in + this unit. + amount: The reading. + date: Date as YYYY-MM-DD (defaults to today). + """ + try: + client = _get_client() + result = client.add_biometric(metric_id, unit_id, amount, _parse_date(date)) + return _ok({"biometric": result}) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/tests/test_biometrics_write.py b/tests/test_biometrics_write.py new file mode 100644 index 0000000..d8d76b7 --- /dev/null +++ b/tests/test_biometrics_write.py @@ -0,0 +1,53 @@ +"""add_biometric: record a reading through /api/v2/add_biometric.""" + +from __future__ import annotations + +import math +from datetime import date + +import pytest +from test_client import make_cold_client + +from cronometer_api_mcp.client import CronometerError + +ADDED = {"heightInCM": 173, "weightInKG": 75.4, "messages": [], "id": 1894305060} + + +def test_add_biometric_sends_new_reading(tmp_path): + client, state = make_cold_client(tmp_path, [ADDED]) + + result = client.add_biometric(1, 2, 165.5, date(1900, 1, 2)) + + sent = state["payloads"][0] + assert sent["config"] == {"call_version": 1} + assert sent["biometric"] == { + "biometricId": 0, + "type": "Biometric", + "metricId": 1, + "unitId": 2, + "amount": 165.5, + "day": "1900-1-2", + "order": 1, + "meta": None, + "externalId": None, + "source": None, + "userId": 42, + } + assert result["biometricId"] == 1894305060 + assert result["day"] == "1900-01-02" + + +@pytest.mark.parametrize("bad", [-1.0, math.nan, math.inf]) +def test_add_biometric_rejects_bad_amounts(tmp_path, bad): + client, state = make_cold_client(tmp_path, [ADDED]) + + with pytest.raises(ValueError, match="non-negative"): + client.add_biometric(1, 1, bad) + assert state["payloads"] == [] + + +def test_add_biometric_raises_without_id(tmp_path): + client, _ = make_cold_client(tmp_path, [{"messages": []}]) + + with pytest.raises(CronometerError, match="Failed to add biometric"): + client.add_biometric(1, 1, 70.0) diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..372ba23 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -12,6 +12,7 @@ import threading EXPECTED_TOOLS = { + "add_biometric", "add_custom_food", "add_food_entry", "add_recipe", From 8dbff0ae3591d2cd8187cf24175b9a8e784ea590 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:37:50 +0200 Subject: [PATCH 2/2] tests: use neutral example values Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- tests/test_biometrics_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_biometrics_write.py b/tests/test_biometrics_write.py index d8d76b7..ab23b71 100644 --- a/tests/test_biometrics_write.py +++ b/tests/test_biometrics_write.py @@ -10,7 +10,7 @@ from cronometer_api_mcp.client import CronometerError -ADDED = {"heightInCM": 173, "weightInKG": 75.4, "messages": [], "id": 1894305060} +ADDED = {"heightInCM": 180, "weightInKG": 80.0, "messages": [], "id": 1894305060} def test_add_biometric_sends_new_reading(tmp_path):