Hosted version for Claude.ai, ChatGPT, and Grok coming soon. Join the waitlist →
An MCP (Model Context Protocol) server for Cronometer nutrition tracking, built on the reverse-engineered mobile REST API.
Unlike cronometer-mcp, which takes a comprehensive GWT-RPC approach against Cronometer's web backend, this server talks to the same JSON REST API used by the Cronometer Android app -- with clean payloads and stable, versioned endpoints.
- Food log -- diary entries with food names, amounts, meal groups
- Nutrition data -- daily macro/micro totals and nutrition scores with per-nutrient confidence
- Food search -- search the Cronometer food database, get detailed nutrition info
- Diary management -- add/remove entries, copy days, mark days complete
- Custom foods -- create foods with custom nutrition data
- Recipes -- create recipes from database foods or free text, edit them in place, and retire them
- Macro targets -- read weekly schedule and saved templates
- Fasting -- view history and aggregate statistics
- Biometrics -- weight, body fat, heart rate, and other tracked metrics over a date range
1. Install uv
curl -LsSf https://astral.sh/uv/install.sh | shexport CRONOMETER_USERNAME="your@email.com"
export CRONOMETER_PASSWORD="your-password"If the account has two-factor authentication enabled, /api/v2/login
answers TOTP_CODE_REQUIRED unless the request carries the current 6-digit
code. Give the server the base32 key that Cronometer showed when 2FA was set
up (the same key you scanned into your authenticator app) and it derives the
code itself at every login (RFC 6238, SHA-1, 30 s period):
export CRONOMETER_TOTP_SECRET="ABCD EFGH IJKL MNOP QRST UVWX YZ23 4567"Spaces and lowercase are fine. Leave it unset for accounts without 2FA.
Diary entries are stamped in your Cronometer account's timezone, which the server reports at login. If that zone is wrong (for example, an older build had reset it) you can force a specific IANA zone without changing your account settings:
export CRONOMETER_ACCOUNT_TZ="America/Los_Angeles"When set, this takes precedence over both the value reported at login and any cached session, so it also overrides a stale cached timezone.
Each diary entry belongs to a numbered diary group, but the API exposes no group names, so names you set in the app (renamed groups, added groups such as an evening snack) can't be read. List them, in order, for groups 1, 2, 3, ...:
export CRONOMETER_DIARY_GROUPS="breakfast,lunch,afternoon snack,dinner,evening snack"Unset, it defaults to Cronometer's breakfast,lunch,dinner,snacks. Names are
case-insensitive and whitespace is normalized; an empty or repeated name,
auto, or a group N lookalike fails at startup. get_food_log and
get_nutrient_sources label entries with these names, and the write tools
take them as diary_group. A group past the last name reads and writes as
"group N". add_food_entry's "auto" picks breakfast, lunch, dinner
or snacks by the hour, and fails if that name isn't configured.
The app's Diary Settings number groups one higher than the API: its "Group 1: Uncategorized" is API group 0, its "Group 2" is API group 1 (the first name in the list), and so on.
uvx downloads and runs the server on demand -- no separate install step.
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"cronometer": {
"type": "local",
"command": ["uvx", "cronometer-api-mcp"],
"environment": {
"CRONOMETER_USERNAME": "{env:CRONOMETER_USERNAME}",
"CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}",
"CRONOMETER_TOTP_SECRET": "{env:CRONOMETER_TOTP_SECRET}",
"CRONOMETER_ACCOUNT_TZ": "{env:CRONOMETER_ACCOUNT_TZ}"
},
"enabled": true
}
}
}{
"mcpServers": {
"cronometer": {
"command": "uvx",
"args": ["cronometer-api-mcp"],
"env": {
"CRONOMETER_USERNAME": "your@email.com",
"CRONOMETER_PASSWORD": "your-password",
"CRONOMETER_TOTP_SECRET": "your-base32-key",
"CRONOMETER_ACCOUNT_TZ": "America/Los_Angeles"
}
}
}
}| Tool | Description |
|---|---|
get_food_log |
Diary entries for reviewing or editing a day: per entry its serving ID, meal, food and food ID, measure ID, grams, measure name and quantity, and a few key nutrients (or ones you name); per day the totals of those nutrients, whether the day is marked complete, an energy_summary (target/consumed/remaining kcal), and non-food entries (exercise, biometrics). For every tracked nutrient's total use get_daily_nutrition; for a food's full profile, get_food_details |
get_daily_nutrition |
Consumed macro and micronutrient totals for every nutrient tracked in Cronometer |
get_nutrient_sources |
Top foods behind one or more nutrient totals (e.g. "where did my sodium come from?"): total, split by diary group, and ranked sources with grams eaten, amount, and percent, aggregated across entries and days. Covers any nutrient, not only the few get_food_log reports per entry |
get_nutrition_scores |
Category scores (Vitamins, Minerals, etc.) with per-nutrient consumed amounts and confidence levels |
| Tool | Description |
|---|---|
search_foods |
Search the Cronometer food database by name |
get_food_details |
Full nutrition profile and serving sizes for a food; for a recipe, also its ingredients and notes |
| Tool | Description |
|---|---|
add_food_entry |
Log a food serving to the diary |
add_food_entries |
Log many servings across any dates in one call; every item is checked before anything is logged, and a mid-batch failure reports what was added so it can be rolled back |
remove_food_entry |
Remove one or more diary entries from one date |
remove_food_entries |
Remove entries across any dates in one call; every ID is looked up before anything is removed, and a mid-batch failure reports what was removed |
add_custom_food |
Create a custom food with specified nutrition |
update_custom_food |
Edit a custom food in place: name, nutrition, or serving size |
delete_custom_food |
Retire a custom food or recipe so it leaves search (existing diary entries are kept) |
add_recipe |
Create a recipe from existing foods referenced by ID and gram weight |
import_recipe |
Create a recipe from a free-text ingredient list; Cronometer matches each line to a database food and converts the amount to grams |
update_recipe |
Edit a recipe in place, keeping its food ID: replace its ingredient list, rename it, change its serving's name, quantity or weight, or its notes. Cronometer re-derives the nutrients from the new ingredients |
copy_day |
Copy all entries from the previous day |
mark_day_complete |
Mark a diary day as complete or incomplete |
| Tool | Description |
|---|---|
get_macro_targets |
Weekly macro schedule and saved target templates |
get_fasting_history |
Fasting history within a date range |
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 |
All date parameters use YYYY-MM-DD format and default to today when omitted.
get_food_log, get_daily_nutrition, get_nutrient_sources, and get_nutrition_scores also accept
"today", "yesterday", and "N days ago" as their date. These are resolved
at call time using your Cronometer account timezone, so Claude can pass relative
inputs without relying on dates from earlier in a long conversation.
get_food_log, get_daily_nutrition, and get_nutrient_sources accept days (1–31, default 1), counting
back from the inclusive end date:
get_food_log(date="yesterday")
get_nutrition_scores(date="3 days ago")
get_daily_nutrition(days=3) # Last 3 days, including today
get_daily_nutrition(date="yesterday", days=3) # Previous 3 complete calendar days
get_food_log(days=7) # Recent meals, including today
get_nutrient_sources("sodium", date="yesterday") # Yesterday's top sodium sources
get_nutrient_sources(["protein", "fiber"], days=7, top_n=5)For “my calories the last few days,” use get_daily_nutrition(days=3).
Single-day responses retain their existing shape. Multi-day responses contain
start_date, end_date, and a days list of daily results, oldest first.
get_nutrient_sources is the exception: it aggregates the whole range into one
ranking, so a multi-day response has start_date and end_date but no days list.
Returned dates are always concrete YYYY-MM-DD values.
stdio only. For remote/hosted use, the stdio server is wrapped by
supergateway (see Dockerfile),
which owns the HTTP listener and exposes MCP streamable-HTTP at /mcp. The
server has no built-in authentication — any remote deployment must sit
behind an authenticating gateway or reverse proxy.
For local development, copy .env.example to .env and fill in your credentials:
cp .env.example .env
# edit .env
uv run cronometer-api-mcpThe CLI auto-loads .env on startup (dev convenience only). Real environment variables always win over .env, so production deployments and MCP client env blocks are unaffected.
This server communicates with mobile.cronometer.com -- the same REST API used by the Cronometer Android/Flutter app. The API was reverse-engineered through:
- Static analysis of
libapp.so(Dart AOT snapshot) from the APK to discover endpoint names - Traffic interception via Frida + mitmproxy to capture exact request/response formats
- Trial-and-error against the live API to confirm payload shapes
The API uses two protocols:
- v2 (
POST /api/v2/*) -- JSON-body auth, used for most operations (food search, diary read/write, nutrition, fasting, macros, biometrics) - v3 (
DELETE /api/v3/user/{id}/*) -- Header-based auth (x-crono-session), used for diary entry deletion
Custom foods and recipes are both saved through POST /api/v2/add_food, which upserts: a food
sent with its existing id is edited in place, and diary entries that use it reflect the edit. For a
recipe, the server derives the nutrients and the "full recipe" weight from the ingredients list on
every save, so the client sends none, and reads both back to check them. The server counts an
ingredient's grams toward the batch only if all of the food's measures are type Weight (what the
web client saves for a serving with a gram weight); with an Atomic one, its nutrients are added but
its grams aren't, so the batch weight comes out short. Custom foods are therefore saved with
Weight servings, and recipes refuse other ingredients. Don't fix an older food by switching an
Atomic measure to Weight: the server stores a diary entry as a count of its measure, with a Weight unit
weighing its value in grams and an Atomic one 100 g, so the switch rescales every entry logged by
it (180 g logged by an Atomic "g" read 1.8 g after switching it to Weight). No hard-delete endpoint for foods is known, so
delete_custom_food sets retired instead (the food leaves search after a few seconds but stays
readable by id, and diary entries that use it are kept).
Retired foods can't be edited. Serving names are sent exactly as given with an explicit quantity,
so add_custom_food, update_custom_food, add_recipe and update_recipe refuse a name that
starts with a number ("2 cookies"): pass serving_quantity=2, serving_name="cookies". When the
server re-parses a new measure's name (it does for some names when the quantity is 1, e.g.
"serving (1/2 batch)"), the client re-sends the measure verbatim, which sticks.
Recipe import is the one asynchronous operation: import_recipe returns a job id, and poll_async_result is polled until the server reports 100% progress and attaches the parsed ingredients.
You can use the client directly:
from cronometer_api_mcp.client import CronometerClient
from datetime import date
client = CronometerClient()
# Search for foods
results = client.search_food("chicken breast")
# Get food details
food = client.get_food(results[0]["id"])
# Log a serving
client.add_serving(
food_id=food["id"],
measure_id=food["measures"][0]["id"],
grams=200,
diary_group=3, # by default 1 breakfast, 2 lunch, 3 dinner, 4 snacks; 5+ groups added in the app
)
# Get today's diary
diary = client.get_diary()
# Import a recipe from a free-text ingredient list
recipe = client.import_recipe("one hot dog\nketchup\nbun")
print(recipe["food_id"], recipe["ingredients"])
# Parse without saving, to review the matches first
preview = client.import_recipe("2 tbsp olive oil\n200g chicken", save=False)
# Get nutrition scores
scores = client.get_nutrition_scores()MIT