cronometer-api-mcp 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cronometer_api_mcp-0.2.3/.env.example +6 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/PKG-INFO +21 -3
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/README.md +20 -2
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/pyproject.toml +1 -1
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/server.json +14 -2
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/client.py +75 -24
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_timezone.py +177 -2
- cronometer_api_mcp-0.2.3/tests/test_totp.py +107 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/uv.lock +21 -21
- cronometer_api_mcp-0.2.2/.env.example +0 -2
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.dockerignore +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/dependabot.yml +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/ci.yml +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/docker.yml +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/publish.yml +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.gitignore +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.python-version +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/Dockerfile +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/LICENSE +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/__init__.py +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/server.py +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_client.py +0 -0
- {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_server_import.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cronometer-api-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: MCP server for Cronometer nutrition data using the mobile REST API
|
|
5
5
|
Author: Randy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,6 +59,20 @@ export CRONOMETER_USERNAME="your@email.com"
|
|
|
59
59
|
export CRONOMETER_PASSWORD="your-password"
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
#### Optional: two-factor authentication
|
|
63
|
+
|
|
64
|
+
If the account has two-factor authentication enabled, `/api/v2/login`
|
|
65
|
+
answers `TOTP_CODE_REQUIRED` unless the request carries the current 6-digit
|
|
66
|
+
code. Give the server the base32 key that Cronometer showed when 2FA was set
|
|
67
|
+
up (the same key you scanned into your authenticator app) and it derives the
|
|
68
|
+
code itself at every login (RFC 6238, SHA-1, 30 s period):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CRONOMETER_TOTP_SECRET="ABCD EFGH IJKL MNOP QRST UVWX YZ23 4567"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Spaces and lowercase are fine. Leave it unset for accounts without 2FA.
|
|
75
|
+
|
|
62
76
|
#### Optional: override the account timezone
|
|
63
77
|
|
|
64
78
|
Diary entries are stamped in your Cronometer account's timezone, which the
|
|
@@ -88,7 +102,9 @@ cached session, so it also overrides a stale cached timezone.
|
|
|
88
102
|
"command": ["uvx", "cronometer-api-mcp"],
|
|
89
103
|
"environment": {
|
|
90
104
|
"CRONOMETER_USERNAME": "{env:CRONOMETER_USERNAME}",
|
|
91
|
-
"CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}"
|
|
105
|
+
"CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}",
|
|
106
|
+
"CRONOMETER_TOTP_SECRET": "{env:CRONOMETER_TOTP_SECRET}",
|
|
107
|
+
"CRONOMETER_ACCOUNT_TZ": "{env:CRONOMETER_ACCOUNT_TZ}"
|
|
92
108
|
},
|
|
93
109
|
"enabled": true
|
|
94
110
|
}
|
|
@@ -106,7 +122,9 @@ cached session, so it also overrides a stale cached timezone.
|
|
|
106
122
|
"args": ["cronometer-api-mcp"],
|
|
107
123
|
"env": {
|
|
108
124
|
"CRONOMETER_USERNAME": "your@email.com",
|
|
109
|
-
"CRONOMETER_PASSWORD": "your-password"
|
|
125
|
+
"CRONOMETER_PASSWORD": "your-password",
|
|
126
|
+
"CRONOMETER_TOTP_SECRET": "your-base32-key",
|
|
127
|
+
"CRONOMETER_ACCOUNT_TZ": "America/Los_Angeles"
|
|
110
128
|
}
|
|
111
129
|
}
|
|
112
130
|
}
|
|
@@ -39,6 +39,20 @@ export CRONOMETER_USERNAME="your@email.com"
|
|
|
39
39
|
export CRONOMETER_PASSWORD="your-password"
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
#### Optional: two-factor authentication
|
|
43
|
+
|
|
44
|
+
If the account has two-factor authentication enabled, `/api/v2/login`
|
|
45
|
+
answers `TOTP_CODE_REQUIRED` unless the request carries the current 6-digit
|
|
46
|
+
code. Give the server the base32 key that Cronometer showed when 2FA was set
|
|
47
|
+
up (the same key you scanned into your authenticator app) and it derives the
|
|
48
|
+
code itself at every login (RFC 6238, SHA-1, 30 s period):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export CRONOMETER_TOTP_SECRET="ABCD EFGH IJKL MNOP QRST UVWX YZ23 4567"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Spaces and lowercase are fine. Leave it unset for accounts without 2FA.
|
|
55
|
+
|
|
42
56
|
#### Optional: override the account timezone
|
|
43
57
|
|
|
44
58
|
Diary entries are stamped in your Cronometer account's timezone, which the
|
|
@@ -68,7 +82,9 @@ cached session, so it also overrides a stale cached timezone.
|
|
|
68
82
|
"command": ["uvx", "cronometer-api-mcp"],
|
|
69
83
|
"environment": {
|
|
70
84
|
"CRONOMETER_USERNAME": "{env:CRONOMETER_USERNAME}",
|
|
71
|
-
"CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}"
|
|
85
|
+
"CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}",
|
|
86
|
+
"CRONOMETER_TOTP_SECRET": "{env:CRONOMETER_TOTP_SECRET}",
|
|
87
|
+
"CRONOMETER_ACCOUNT_TZ": "{env:CRONOMETER_ACCOUNT_TZ}"
|
|
72
88
|
},
|
|
73
89
|
"enabled": true
|
|
74
90
|
}
|
|
@@ -86,7 +102,9 @@ cached session, so it also overrides a stale cached timezone.
|
|
|
86
102
|
"args": ["cronometer-api-mcp"],
|
|
87
103
|
"env": {
|
|
88
104
|
"CRONOMETER_USERNAME": "your@email.com",
|
|
89
|
-
"CRONOMETER_PASSWORD": "your-password"
|
|
105
|
+
"CRONOMETER_PASSWORD": "your-password",
|
|
106
|
+
"CRONOMETER_TOTP_SECRET": "your-base32-key",
|
|
107
|
+
"CRONOMETER_ACCOUNT_TZ": "America/Los_Angeles"
|
|
90
108
|
}
|
|
91
109
|
}
|
|
92
110
|
}
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/rwestergren/cronometer-api-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "pypi",
|
|
13
13
|
"identifier": "cronometer-api-mcp",
|
|
14
|
-
"version": "0.2.
|
|
14
|
+
"version": "0.2.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -27,6 +27,18 @@
|
|
|
27
27
|
"description": "Your Cronometer account password",
|
|
28
28
|
"isRequired": true,
|
|
29
29
|
"isSecret": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "CRONOMETER_TOTP_SECRET",
|
|
33
|
+
"description": "Base32 key for accounts with two-factor authentication",
|
|
34
|
+
"isRequired": false,
|
|
35
|
+
"isSecret": true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "CRONOMETER_ACCOUNT_TZ",
|
|
39
|
+
"description": "Optional IANA timezone override, for example America/Los_Angeles",
|
|
40
|
+
"isRequired": false,
|
|
41
|
+
"isSecret": false
|
|
30
42
|
}
|
|
31
43
|
]
|
|
32
44
|
}
|
|
@@ -9,9 +9,13 @@ Frida-based traffic capture that established the auth flow and initial
|
|
|
9
9
|
endpoints.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
12
15
|
import json
|
|
13
16
|
import logging
|
|
14
17
|
import os
|
|
18
|
+
import struct
|
|
15
19
|
import threading
|
|
16
20
|
import time
|
|
17
21
|
from datetime import date, datetime
|
|
@@ -24,9 +28,9 @@ logger = logging.getLogger(__name__)
|
|
|
24
28
|
|
|
25
29
|
BASE_URL = "https://mobile.cronometer.com"
|
|
26
30
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
31
|
+
# Only when the zone can't be resolved at all. UTC, not a plausible zone to
|
|
32
|
+
# make fallback more obvious
|
|
33
|
+
_FALLBACK_TIMEZONE = "UTC"
|
|
30
34
|
|
|
31
35
|
# Optional deploy-time override for the account timezone. When set to a valid
|
|
32
36
|
# IANA zone name it is authoritative over both the login response and any
|
|
@@ -120,6 +124,23 @@ class CronometerError(Exception):
|
|
|
120
124
|
"""Raised when a Cronometer API call fails."""
|
|
121
125
|
|
|
122
126
|
|
|
127
|
+
def _totp_code(secret: str, for_time: float | None = None) -> str:
|
|
128
|
+
"""Current RFC 6238 TOTP code (SHA-1, 30 s period, 6 digits) for `secret`.
|
|
129
|
+
|
|
130
|
+
`secret` is the base32 key shown by Cronometer when enabling two-factor
|
|
131
|
+
authentication; authenticator apps display it grouped and sometimes
|
|
132
|
+
lowercased, so whitespace and case are normalized before decoding.
|
|
133
|
+
"""
|
|
134
|
+
normalized = "".join(secret.split()).upper()
|
|
135
|
+
normalized += "=" * (-len(normalized) % 8)
|
|
136
|
+
key = base64.b32decode(normalized)
|
|
137
|
+
counter = int((time.time() if for_time is None else for_time) // 30)
|
|
138
|
+
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
|
139
|
+
offset = digest[-1] & 0x0F
|
|
140
|
+
code = int.from_bytes(digest[offset : offset + 4], "big") & 0x7FFFFFFF
|
|
141
|
+
return f"{code % 1_000_000:06d}"
|
|
142
|
+
|
|
143
|
+
|
|
123
144
|
class CronometerClient:
|
|
124
145
|
"""Stateful client for the Cronometer mobile API.
|
|
125
146
|
|
|
@@ -185,10 +206,13 @@ class CronometerClient:
|
|
|
185
206
|
return
|
|
186
207
|
token = data.get("token")
|
|
187
208
|
user_id = data.get("user_id")
|
|
188
|
-
timezone
|
|
189
|
-
#
|
|
190
|
-
# re-
|
|
191
|
-
if not
|
|
209
|
+
# Missing key is the pre-timezone schema: re-login to learn the zone.
|
|
210
|
+
# Explicit null means the account has none, so honour the cache instead
|
|
211
|
+
# of re-logging in every start against a rate-limited endpoint.
|
|
212
|
+
if "timezone" not in data:
|
|
213
|
+
return
|
|
214
|
+
timezone = data["timezone"]
|
|
215
|
+
if timezone is not None and not isinstance(timezone, str):
|
|
192
216
|
return
|
|
193
217
|
if isinstance(token, str) and isinstance(user_id, int):
|
|
194
218
|
self._user_id = user_id
|
|
@@ -251,6 +275,11 @@ class CronometerClient:
|
|
|
251
275
|
)
|
|
252
276
|
return username, password
|
|
253
277
|
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _totp_user_code() -> str | None:
|
|
280
|
+
secret = os.getenv("CRONOMETER_TOTP_SECRET")
|
|
281
|
+
return _totp_code(secret) if secret else None
|
|
282
|
+
|
|
254
283
|
def login(self) -> None:
|
|
255
284
|
"""Authenticate with Cronometer and cache the session token.
|
|
256
285
|
|
|
@@ -271,7 +300,9 @@ class CronometerClient:
|
|
|
271
300
|
# leaves the account setting untouched and the response echoes the
|
|
272
301
|
# account's real zone.
|
|
273
302
|
"timezone": None,
|
|
274
|
-
|
|
303
|
+
# 6-digit TOTP code for accounts with two-factor authentication,
|
|
304
|
+
# derived from CRONOMETER_TOTP_SECRET; null when 2FA is not in use.
|
|
305
|
+
"userCode": self._totp_user_code(),
|
|
275
306
|
"build": "4.48.2 b2807-a",
|
|
276
307
|
"device": "Android 14 (SDK 34), Google Pixel 6 Pro",
|
|
277
308
|
"firebaseToken": "",
|
|
@@ -295,6 +326,12 @@ class CronometerClient:
|
|
|
295
326
|
data = resp.json()
|
|
296
327
|
|
|
297
328
|
if data.get("result") != "SUCCESS" and "sessionKey" not in data:
|
|
329
|
+
if data.get("error") == "TOTP_CODE_REQUIRED":
|
|
330
|
+
raise CronometerError(
|
|
331
|
+
"Login failed: the account has two-factor authentication "
|
|
332
|
+
"enabled; set CRONOMETER_TOTP_SECRET to the base32 key "
|
|
333
|
+
"shown when 2FA was set up"
|
|
334
|
+
)
|
|
298
335
|
raise CronometerError(f"Login failed: {data}")
|
|
299
336
|
|
|
300
337
|
self._user_id = data["id"]
|
|
@@ -463,38 +500,52 @@ class CronometerClient:
|
|
|
463
500
|
return None
|
|
464
501
|
return name
|
|
465
502
|
|
|
466
|
-
def _resolve_timezone(self, response_tz: str | None) -> str:
|
|
467
|
-
"""
|
|
503
|
+
def _resolve_timezone(self, response_tz: str | None) -> str | None:
|
|
504
|
+
"""Env override, else the login response. None when neither is usable.
|
|
468
505
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
no longer overwrites the account's server-side zone; see #29).
|
|
472
|
-
3. The historical default.
|
|
506
|
+
Validates the response: storing an unknown name defers the failure to
|
|
507
|
+
``_tzinfo``, where it is no longer attributable to Cronometer.
|
|
473
508
|
"""
|
|
474
509
|
env = self._env_timezone()
|
|
475
510
|
if env:
|
|
476
511
|
return env
|
|
477
512
|
if isinstance(response_tz, str) and response_tz:
|
|
478
|
-
|
|
479
|
-
|
|
513
|
+
try:
|
|
514
|
+
ZoneInfo(response_tz)
|
|
515
|
+
return response_tz
|
|
516
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
517
|
+
logger.warning(
|
|
518
|
+
"Cronometer reported unknown timezone %r; ignoring", response_tz
|
|
519
|
+
)
|
|
520
|
+
return None
|
|
480
521
|
|
|
481
522
|
def _tzinfo(self) -> ZoneInfo:
|
|
482
|
-
"""
|
|
523
|
+
"""The account's zone, logging in if that's the only way to learn it.
|
|
483
524
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
525
|
+
Every consumer funnels through here, so this is where the zone has to
|
|
526
|
+
be guaranteed resolved: callers used to read the clock pre-auth and get
|
|
527
|
+
the fallback, correcting only once something else triggered login.
|
|
528
|
+
|
|
529
|
+
Env override first, so an injected zone needs no network.
|
|
487
530
|
"""
|
|
488
|
-
name = self.
|
|
531
|
+
name = self._env_timezone()
|
|
532
|
+
if not name:
|
|
533
|
+
self._ensure_auth()
|
|
534
|
+
name = self._timezone
|
|
535
|
+
if not name:
|
|
536
|
+
logger.warning(
|
|
537
|
+
"No account timezone available; stamping in %s", _FALLBACK_TIMEZONE
|
|
538
|
+
)
|
|
539
|
+
return ZoneInfo(_FALLBACK_TIMEZONE)
|
|
489
540
|
try:
|
|
490
541
|
return ZoneInfo(name)
|
|
491
542
|
except ZoneInfoNotFoundError, ValueError:
|
|
492
543
|
logger.warning(
|
|
493
|
-
"Unknown account timezone %r;
|
|
544
|
+
"Unknown account timezone %r; stamping in %s",
|
|
494
545
|
name,
|
|
495
|
-
|
|
546
|
+
_FALLBACK_TIMEZONE,
|
|
496
547
|
)
|
|
497
|
-
return ZoneInfo(
|
|
548
|
+
return ZoneInfo(_FALLBACK_TIMEZONE)
|
|
498
549
|
|
|
499
550
|
def now(self) -> datetime:
|
|
500
551
|
"""Current wall-clock time in the account's timezone (aware)."""
|
|
@@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo
|
|
|
14
14
|
|
|
15
15
|
import pytest
|
|
16
16
|
|
|
17
|
-
from cronometer_api_mcp.client import CronometerClient
|
|
17
|
+
from cronometer_api_mcp.client import _FALLBACK_TIMEZONE, CronometerClient
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class FakeResp:
|
|
@@ -306,4 +306,179 @@ def test_pre_timezone_cache_is_rejected(tmp_path, monkeypatch):
|
|
|
306
306
|
def test_unknown_timezone_falls_back(tmp_path):
|
|
307
307
|
"""An unresolvable zone name falls back rather than raising."""
|
|
308
308
|
client = _client(tmp_path, "Not/AZone")
|
|
309
|
-
assert client._tzinfo() == ZoneInfo(
|
|
309
|
+
assert client._tzinfo() == ZoneInfo(_FALLBACK_TIMEZONE)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---- cold start: the clock must not be read before the zone is known -------
|
|
313
|
+
#
|
|
314
|
+
# Regression: callers read the clock pre-auth, so the first entry in a fresh
|
|
315
|
+
# container stamped in the fallback zone and later ones were correct -- which
|
|
316
|
+
# read as intermittent drift.
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cold_client(
|
|
320
|
+
tmp_path: Path, response_tz: str | None
|
|
321
|
+
) -> tuple[CronometerClient, dict]:
|
|
322
|
+
"""An unauthenticated client plus a captured-payload dict.
|
|
323
|
+
|
|
324
|
+
Stubs login and add_serving on one fake. Nothing pre-seeds ``_timezone`` --
|
|
325
|
+
that is the condition under test.
|
|
326
|
+
"""
|
|
327
|
+
client = CronometerClient(session_path=tmp_path / "session.json")
|
|
328
|
+
captured: dict = {"logins": 0}
|
|
329
|
+
|
|
330
|
+
def fake_post(endpoint, json=None):
|
|
331
|
+
if endpoint == "/api/v2/login":
|
|
332
|
+
captured["logins"] += 1
|
|
333
|
+
body = {"result": "SUCCESS", "id": 123, "sessionKey": "TOKEN"}
|
|
334
|
+
if response_tz is not None:
|
|
335
|
+
body["timezone"] = response_tz
|
|
336
|
+
return FakeResp(body)
|
|
337
|
+
captured["endpoint"] = endpoint
|
|
338
|
+
captured["payload"] = json
|
|
339
|
+
return FakeResp({"result": "SUCCESS", "id": 999})
|
|
340
|
+
|
|
341
|
+
client._http.post = fake_post # type: ignore[method-assign]
|
|
342
|
+
return client, captured
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_first_add_serving_in_a_cold_client_stamps_in_account_zone(
|
|
346
|
+
tmp_path, frozen_utc, monkeypatch
|
|
347
|
+
):
|
|
348
|
+
"""The prod regression: 18:01 UTC is 11:01 PDT, not 14:01 Eastern."""
|
|
349
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
350
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
351
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
352
|
+
client, captured = _cold_client(tmp_path, "America/Los_Angeles")
|
|
353
|
+
|
|
354
|
+
assert client._timezone is None # nothing resolved yet
|
|
355
|
+
client.add_serving(food_id=1, measure_id=0, grams=100.0)
|
|
356
|
+
|
|
357
|
+
serving = captured["payload"]["serving"]
|
|
358
|
+
assert serving["time"] == "11:1:30"
|
|
359
|
+
assert serving["day"] == "2026-7-27"
|
|
360
|
+
# Lunch; Eastern would have made it Dinner.
|
|
361
|
+
assert serving["order"] == (2 << 16) | 1
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_cold_client_day_bucketing_uses_account_zone(tmp_path, monkeypatch):
|
|
365
|
+
"""A late-evening PDT entry must not slip a day, as a fallback zone would."""
|
|
366
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
367
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
368
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
369
|
+
|
|
370
|
+
class LateEvening(_dt.datetime):
|
|
371
|
+
# 05:30 UTC on the 28th == 22:30 PDT on the 27th.
|
|
372
|
+
_fixed_utc = _dt.datetime(2026, 7, 28, 5, 30, 0, tzinfo=_dt.UTC)
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def now(cls, tz=None):
|
|
376
|
+
if tz is None:
|
|
377
|
+
return cls._fixed_utc.replace(tzinfo=None)
|
|
378
|
+
return cls._fixed_utc.astimezone(tz)
|
|
379
|
+
|
|
380
|
+
monkeypatch.setattr("cronometer_api_mcp.client.datetime", LateEvening)
|
|
381
|
+
client, captured = _cold_client(tmp_path, "America/Los_Angeles")
|
|
382
|
+
|
|
383
|
+
client.add_serving(food_id=1, measure_id=0, grams=100.0)
|
|
384
|
+
|
|
385
|
+
assert captured["payload"]["serving"]["day"] == "2026-7-27"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_env_override_needs_no_login_to_stamp(tmp_path, frozen_utc, monkeypatch):
|
|
389
|
+
"""An injected zone leaves nothing to learn, so no login -- which is what
|
|
390
|
+
makes a cold container correct on its first entry."""
|
|
391
|
+
monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles")
|
|
392
|
+
client, captured = _cold_client(tmp_path, "America/New_York")
|
|
393
|
+
|
|
394
|
+
assert client._tzinfo() == ZoneInfo("America/Los_Angeles")
|
|
395
|
+
assert captured["logins"] == 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_tzinfo_authenticates_when_zone_is_unknown(tmp_path, frozen_utc, monkeypatch):
|
|
399
|
+
"""Without an override the login response is the only source."""
|
|
400
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
401
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
402
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
403
|
+
client, captured = _cold_client(tmp_path, "America/Los_Angeles")
|
|
404
|
+
|
|
405
|
+
assert client._tzinfo() == ZoneInfo("America/Los_Angeles")
|
|
406
|
+
assert captured["logins"] == 1
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_cold_today_uses_account_zone(tmp_path, frozen_utc, monkeypatch):
|
|
410
|
+
"""today() defaults a date for many tools, so it must resolve too."""
|
|
411
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
412
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
413
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
414
|
+
client, _ = _cold_client(tmp_path, "America/Los_Angeles")
|
|
415
|
+
|
|
416
|
+
assert client.today() == _dt.date(2026, 7, 27)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---- fallback must not look plausible -------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_missing_response_zone_falls_back_to_utc(tmp_path, frozen_utc, monkeypatch):
|
|
423
|
+
"""No reported zone -> UTC. Eastern was a silent 3h lie for a PDT user."""
|
|
424
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
425
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
426
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
427
|
+
client, _ = _cold_client(tmp_path, None)
|
|
428
|
+
|
|
429
|
+
assert client._tzinfo() == ZoneInfo("UTC")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_unresolvable_response_zone_is_rejected_at_the_boundary(
|
|
433
|
+
tmp_path, frozen_utc, monkeypatch
|
|
434
|
+
):
|
|
435
|
+
"""Rejected where it's still attributable to Cronometer."""
|
|
436
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
|
|
437
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
|
|
438
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
439
|
+
client, _ = _cold_client(tmp_path, "Mars/Olympus_Mons")
|
|
440
|
+
|
|
441
|
+
client.login()
|
|
442
|
+
assert client._timezone is None
|
|
443
|
+
assert client._tzinfo() == ZoneInfo("UTC")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_unknown_stored_zone_falls_back_to_utc(tmp_path):
|
|
447
|
+
"""A zone this tzdata build can't construct is our problem, not theirs."""
|
|
448
|
+
client = _client(tmp_path, "Not/AZone")
|
|
449
|
+
assert client._tzinfo() == ZoneInfo("UTC")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---- session cache must not thrash logins for zoneless accounts ------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_cache_with_null_timezone_is_reused(tmp_path, monkeypatch):
|
|
456
|
+
"""None is now a legitimate resolved value, persisted as null. Treating it
|
|
457
|
+
like the legacy schema would re-login on every container start."""
|
|
458
|
+
import json
|
|
459
|
+
|
|
460
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "")
|
|
461
|
+
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
|
|
462
|
+
(tmp_path / "session.json").write_text(
|
|
463
|
+
json.dumps({"username": "", "user_id": 123, "token": "TOKEN", "timezone": None})
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
client = CronometerClient(session_path=tmp_path / "session.json")
|
|
467
|
+
|
|
468
|
+
assert client._token == "TOKEN"
|
|
469
|
+
assert client._timezone is None
|
|
470
|
+
assert client._tzinfo() == ZoneInfo(_FALLBACK_TIMEZONE)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_cache_predating_timezone_support_is_still_rejected(tmp_path, monkeypatch):
|
|
474
|
+
"""No timezone key is the legacy schema: re-login to learn the zone."""
|
|
475
|
+
import json
|
|
476
|
+
|
|
477
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "")
|
|
478
|
+
(tmp_path / "session.json").write_text(
|
|
479
|
+
json.dumps({"username": "", "user_id": 123, "token": "TOKEN"})
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
client = CronometerClient(session_path=tmp_path / "session.json")
|
|
483
|
+
|
|
484
|
+
assert client._token is None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for optional TOTP (two-factor) support at login.
|
|
2
|
+
|
|
3
|
+
Accounts with two-factor authentication enabled get `{"result": "FAIL",
|
|
4
|
+
"error": "TOTP_CODE_REQUIRED"}` from /api/v2/login unless the request carries
|
|
5
|
+
the current 6-digit code in `userCode`. When CRONOMETER_TOTP_SECRET is set the
|
|
6
|
+
client derives that code itself (RFC 6238, SHA-1, 30 s period, 6 digits).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from cronometer_api_mcp.client import CronometerClient, CronometerError, _totp_code
|
|
16
|
+
|
|
17
|
+
# RFC 6238 Appendix B test vectors (SHA-1). The secret is the ASCII string
|
|
18
|
+
# "12345678901234567890"; the 6-digit codes are the last six digits of the
|
|
19
|
+
# 8-digit values listed in the RFC.
|
|
20
|
+
RFC6238_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
|
21
|
+
RFC6238_VECTORS = [
|
|
22
|
+
(59, "287082"),
|
|
23
|
+
(1111111109, "081804"),
|
|
24
|
+
(1234567890, "005924"),
|
|
25
|
+
(2000000000, "279037"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
SUCCESS_BODY = {
|
|
29
|
+
"result": "SUCCESS",
|
|
30
|
+
"id": 123,
|
|
31
|
+
"sessionKey": "TOKEN",
|
|
32
|
+
"timezone": "UTC",
|
|
33
|
+
}
|
|
34
|
+
TOTP_REQUIRED_BODY = {"result": "FAIL", "error": "TOTP_CODE_REQUIRED"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FakeResp:
|
|
38
|
+
def __init__(self, body) -> None:
|
|
39
|
+
self._body = body
|
|
40
|
+
self.status_code = 200
|
|
41
|
+
|
|
42
|
+
def raise_for_status(self) -> None: # pragma: no cover - trivial
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def json(self):
|
|
46
|
+
return self._body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_client(tmp_path: Path, body: dict):
|
|
50
|
+
"""Client whose login POST is captured and answered with `body`."""
|
|
51
|
+
client = CronometerClient(session_path=tmp_path / "session.json")
|
|
52
|
+
captured: dict = {}
|
|
53
|
+
|
|
54
|
+
def fake_post(endpoint, json=None):
|
|
55
|
+
captured["endpoint"] = endpoint
|
|
56
|
+
captured["payload"] = json
|
|
57
|
+
return FakeResp(body)
|
|
58
|
+
|
|
59
|
+
client._http.post = fake_post # type: ignore[method-assign]
|
|
60
|
+
return client, captured
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def credentials(monkeypatch):
|
|
65
|
+
monkeypatch.setenv("CRONOMETER_USERNAME", "user@example.com")
|
|
66
|
+
monkeypatch.setenv("CRONOMETER_PASSWORD", "secret")
|
|
67
|
+
monkeypatch.delenv("CRONOMETER_TOTP_SECRET", raising=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize(("at", "expected"), RFC6238_VECTORS)
|
|
71
|
+
def test_totp_code_matches_rfc6238_sha1_vectors(at, expected):
|
|
72
|
+
assert _totp_code(RFC6238_SECRET_B32, at) == expected
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_totp_code_accepts_lowercase_and_spaced_secret():
|
|
76
|
+
"""Authenticator apps display the key grouped and sometimes lowercased."""
|
|
77
|
+
spaced = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq"
|
|
78
|
+
assert _totp_code(spaced, 59) == "287082"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_login_sends_null_user_code_without_secret(credentials, tmp_path):
|
|
82
|
+
client, captured = make_client(tmp_path, SUCCESS_BODY)
|
|
83
|
+
|
|
84
|
+
client.login()
|
|
85
|
+
|
|
86
|
+
assert captured["endpoint"] == "/api/v2/login"
|
|
87
|
+
assert captured["payload"]["userCode"] is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_login_sends_current_totp_code_when_secret_set(
|
|
91
|
+
credentials, tmp_path, monkeypatch
|
|
92
|
+
):
|
|
93
|
+
monkeypatch.setenv("CRONOMETER_TOTP_SECRET", RFC6238_SECRET_B32)
|
|
94
|
+
client, captured = make_client(tmp_path, SUCCESS_BODY)
|
|
95
|
+
|
|
96
|
+
client.login()
|
|
97
|
+
|
|
98
|
+
code = captured["payload"]["userCode"]
|
|
99
|
+
assert isinstance(code, str) and len(code) == 6 and code.isdigit()
|
|
100
|
+
assert code == _totp_code(RFC6238_SECRET_B32)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_login_totp_required_without_secret_points_at_env_var(credentials, tmp_path):
|
|
104
|
+
client, _ = make_client(tmp_path, TOTP_REQUIRED_BODY)
|
|
105
|
+
|
|
106
|
+
with pytest.raises(CronometerError, match="CRONOMETER_TOTP_SECRET"):
|
|
107
|
+
client.login()
|
|
@@ -554,27 +554,27 @@ wheels = [
|
|
|
554
554
|
|
|
555
555
|
[[package]]
|
|
556
556
|
name = "ruff"
|
|
557
|
-
version = "0.16.
|
|
558
|
-
source = { registry = "https://pypi.org/simple" }
|
|
559
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
560
|
-
wheels = [
|
|
561
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
562
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
563
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
564
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
565
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
566
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
567
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
568
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
569
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
570
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
571
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
572
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
573
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
574
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
575
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
576
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
577
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
557
|
+
version = "0.16.6"
|
|
558
|
+
source = { registry = "https://pypi.org/simple" }
|
|
559
|
+
sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
|
|
560
|
+
wheels = [
|
|
561
|
+
{ url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
|
|
562
|
+
{ url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
|
|
563
|
+
{ url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
|
|
564
|
+
{ url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
|
|
565
|
+
{ url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
|
|
566
|
+
{ url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
|
|
567
|
+
{ url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
|
|
568
|
+
{ url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
|
|
569
|
+
{ url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
|
|
570
|
+
{ url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
|
|
571
|
+
{ url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
|
|
572
|
+
{ url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
|
|
573
|
+
{ url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
|
|
574
|
+
{ url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
|
|
575
|
+
{ url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
|
|
576
|
+
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
|
577
|
+
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
|
578
578
|
]
|
|
579
579
|
|
|
580
580
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|