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 @@ -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.

Expand Down
59 changes: 59 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
53 changes: 53 additions & 0 deletions tests/test_biometrics_write.py
Original file line number Diff line number Diff line change
@@ -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": 180, "weightInKG": 80.0, "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)
1 change: 1 addition & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import threading

EXPECTED_TOOLS = {
"add_biometric",
"add_custom_food",
"add_food_entry",
"add_recipe",
Expand Down