Skip to main content
Glama
lucia-urcuyo

foodlog

by lucia-urcuyo

foodlog MCP

A calorie/macro/quality journal your agents read and write. One server.py, one SQLite file, stdlib only apart from the MCP SDK.

server.py        server, schema, scoring, KPIs — the whole thing
test_server.py   self-check — python test_server.py
foodlog.db       your data (created on first log; override with $FOODLOG_DB)
api/index.py     serverless entrypoint, inert unless you deploy
PRODUCT.md       what it does and why, without the code
CLAUDE.md        rules for agents working on the repo

Setup

python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/python test_server.py     # should print "all good"

Claude Code (stdio, local — the fast path):

claude mcp add foodlog -s user -- "$PWD/.venv/bin/python" "$PWD/server.py"

ChatGPT (needs a remote HTTPS URL — it can't spawn a local process). Cheapest version, no deploy, no cloud database:

FOODLOG_TRANSPORT=http ./.venv/bin/python server.py    # serves /mcp on :8000
cloudflared tunnel --url http://localhost:8000         # or: ngrok http 8000

Settings → Connectors → Advanced → Developer mode → Add, URL https://<tunnel>/mcp. Your data stays on your laptop; the tunnel dies when you close the terminal. For an always-on URL see Deploying.

Both clients can point at the same foodlog.db (SQLite handles the concurrency at this volume).

Related MCP server: Kitchen MCP Server

Tools

tool

does

log_meal(name, items, meal_type?, date?, time?, notes?)

write a meal; grades it server-side

edit_meal(meal_id, delete?, fields?)

fix a mislog; re-grades automatically

query_log(start?, end?, meal_type?, search?, grade?, limit?)

read the journal

find_food(query)

fuzzy lookup over staples then history — resolves "the usual X"

save_staple(name, serving, macros…, aliases?)

remember a repeat food, per serving

stats(period|start/end)

the KPI report + a rendered markdown table

profile(set_values?)

targets: kcal_target, protein_target_g, weight_kg, late_hour

Set your targets once, or half the KPIs stay null:

profile({"kcal_target": 2000, "protein_target_g": 120, "weight_kg": 60, "late_hour": 20})

How logging works

log_meal takes items, not plates. Each item is one ingredient with its own weight, macros and processing level; the server sums them, computes the grade, and stores the breakdown. The estimation protocol lives in the tool's docstring, so every agent that loads the server gets the same instructions:

  1. one item per ingredient — never one number for the whole plate

  2. grams from visual anchors (chicken breast ~170 g, bread slice ~35 g, tbsp oil ~14 g, egg ~50 g)

  3. macros per ingredient at that weight

  4. a NOVA level per ingredient

  5. fiber_g is required for each ingredient and saved staple, including explicit 0 for fiber-free foods. If unavailable, obtain the value before logging. sugar_g / sat_fat_g / sodium_mg remain optional.

  6. "the usual X" → find_food first, then {"staple": "...", "portions": n}; fiber is scaled from the saved serving. Older staples without fiber must be updated before reuse. Existing meals remain readable, with missing fiber shown as incomplete.

So the three phrasings you wanted all land on the same path:

you say

agent does

"the usual coffee I have in the morning"

find_food("usual coffee")log_meal(items=[{staple:"morning coffee", portions:1}])

"point five ounces of condensed milk"

converts to 14 g, looks up macros, one item, nova 3

[photo of turkish eggs]

eggs / yogurt / butter / chili oil / sourdough as five items with grams

Whatever the server had to guess comes back in estimated_fields, and confidence is high/medium/low by how many micros you supplied. That's your signal to ask a follow-up ("was there butter on that?") instead of silently grading on defaults.

Nutritional grade — the framework

The agent never picks the letter. It reports numbers; score_meal() computes the grade. That is the whole point — ChatGPT and Claude will disagree about whether a croissant is a B or a D, but they can't disagree about 33 g of carbs. Every constant lives in the SCORING dict at the top of server.py; tune those, not the code.

Everything is measured per 100 kcal (density), so a 300 kcal snack and a 900 kcal dinner are judged on the same axis. Grade = quality only. How much you ate is the calorie KPIs' job — keeping them separate is what stops a large healthy dinner from being punished twice.

score = 65
      + min(18, protein_density × 1.6)          protein is the one macro worth chasing
      + min(15, fiber_density   × 5.0)
      − min(20, max(0, effective_sugar − free) × 3.0)
      − min(15, max(0, satfat_density − 1.5)  × 4.0)
      − min(15, max(0, sodium_density − 200)/100 × 1.5)
      + processing:  NOVA 1 +2 · 2 ±0 · 3 −10 · 4 −22

effective_sugar = sugar_density − 2 × fiber_density      fiber cancels intrinsic sugar
free sugar allowance = 5 (NOVA 1–2, i.e. fruit and dairy) · 2 (NOVA 3–4)

A ≥ 80   B ≥ 65   C ≥ 50   D ≥ 35   F < 35

The fiber offset is the load-bearing rule: it's what keeps a banana (14 g sugar, 3 g fiber) a B while orange juice (21 g sugar, 0.5 g fiber) lands at D and a coke at F, without special-casing anything.

NOVA processing level, the one judgement call the agent does make:

1 whole

egg, rice, spinach, chicken, milk, fruit

2 culinary ingredient

oil, butter, sugar, honey, flour, salt

3 processed

bread, cheese, canned beans, cured meat, tinned fish

4 ultra-processed

soda, packaged snacks, nuggets, protein bars, most cereal

Missing micros are filled from NOVA-based per-100-kcal defaults, so a photo-logged meal with only macros still gets a comparable grade — flagged confidence: "low".

Calibration as it stands:

A 88.8  chicken + broccoli + rice      C 62.4  sourdough toast + butter
A 80.0  plain greek yogurt             C 54.2  croissant
B 76.5  banana                         D 39.8  orange juice
B 74.2  turkish eggs + bread           F 23.0  coke
B 72.5  protein bar

Stats — the KPIs and why each one is there

stats(period="today"|"yesterday"|"week"|"month"|"all"), or explicit start/end. Returns five structured blocks plus table, a ready-to-paste markdown summary + a 14-day day-by-day grid + top contributors. Show the table, then talk about what moved.

energyavg_per_day, delta_vs_target, days_within_10pct_of_target, swing_stdev, biggest/lightest day.

swing_stdev is the one people never track. An average of 2000 hides "1400 Monday, 2800 Friday", and the swing predicts how a week felt far better than the mean does.

macros — averages, split_pct (P/C/F by calories), protein_per_kg, protein_per_100kcal, protein_goal_hit_rate_pct.

Hit rate beats average: averaging 120 g over a week can mean seven decent days or two huge ones and five bad ones, and only one of those builds muscle. protein_per_100kcal is the diet-independent version — it says whether your food is protein-dense regardless of how much you ate.

qualitygpa (calorie-weighted!), letter, grade_counts, pct_kcal_from_ab, pct_kcal_from_df, avg_nova, best/worst day, low_confidence_meals.

Weighting by calories is the honest way to average grades: five A-grade black coffees shouldn't cancel one 900 kcal F. pct_kcal_from_df is the actionable number — it's the slice of your week where the wins actually are. low_confidence_meals tells you how much of the report to trust.

timingmedian_first_bite, median_last_bite, median_eating_window_h, pct_kcal_after_20h, kcal_share_by_meal_type, meals_per_day.

Eating window and late-calorie share are the habits most invisible from a food list and most connected to sleep and next-morning hunger. "42% of your calories land after 8pm" is a sentence that changes behaviour; "you ate 2100 calories" isn't.

habitslog_streak_days, distinct_foods, top_calorie_contributors (with % of intake), pct_kcal_from_staples, weekday vs weekend averages and weekend_lift_pct.

top_calorie_contributors is the single most actionable output here — it names the three foods that are your diet, and changing one of them beats any amount of willpower. weekend_lift_pct is the classic blind spot: a perfect Mon–Fri and +40% Sat–Sun nets out to no deficit at all. pct_kcal_from_staples × distinct_foods tells you whether you're on autopilot or improvising.

Deploying

Read this before you deploy anything

You are one person logging four meals a day. Hosting buys you an always-on URL and costs you a network database, cold starts, a public endpoint to secure, and a diary that now lives on someone else's disk. In rough order of laziness:

keeps SQLite

always-on URL

effort

stdio + tunnel when ChatGPT needs it

✅ on your laptop

zero — it already works

Fly.io / Railway with a volume

✅ one file on a mounted disk

a Dockerfile, ~$0–5/mo

Vercel + Turso

⚠️ same SQL, remote engine

this section

Vercel is the only one of the three that forces the database question, because Vercel functions have no persistent disk. If you don't specifically want Vercel, options 1 and 2 are strictly less work. Everything below is for when you do.

Why the database has to change

foodlog.db is a file. Vercel runs your code as serverless functions: no disk that survives a request, several instances running at once, /tmp wiped between invocations. Write a meal on one invocation and the next one starts from an empty file. Nothing about the code is wrong — the storage assumption just doesn't hold there.

What was considered:

option

verdict

Turso (libSQL)

chosen. SQLite's engine, exposed over HTTP. Same SQL, same schema, same aggregation queries — one ~10-line shim (below) and it runs. Generous free tier (500 DBs, 9 GB, 1B row reads/month), which for one person is effectively infinite.

Neon / Vercel Postgres

Rewrites every ? placeholder, the DDL, the ON CONFLICT clauses and the row handling, in exchange for capabilities — concurrent writers, replication, connection pooling — that a single-user journal will never use. Right answer if this ever becomes multi-user.

Supabase

Same as Neon, plus auth and storage you don't need.

Vercel KV / Upstash Redis

Wrong shape. stats() is a pile of range scans and aggregations over meals; a key-value store means fetching the whole month into memory and grouping by hand.

Vercel Blob / a JSON file in the repo

Not a database. No concurrent writes, no queries, and a redeploy overwrites your history.

The deciding argument is that stats() is ~80% of the interesting code and it's all relational aggregation over one table. Whatever it runs on should speak SQL, and the closest thing to the SQLite it already speaks is SQLite.

The database swap — already done

_con() branches on one environment variable. No TURSO_DATABASE_URL and it's stdlib sqlite3 on your local file, exactly as before; set it and the same SQL runs against Turso instead:

url = os.environ.get("TURSO_DATABASE_URL")
if url:
    c = _Conn(libsql.connect(url, auth_token=os.environ.get("TURSO_AUTH_TOKEN", "")))
else:
    c = sqlite3.connect(DB); c.row_factory = sqlite3.Row

_Conn/_Cursor are the whole shim, ~20 lines: libSQL hands back plain tuples and its cursors aren't iterable, while this file does dict(row), row["name"] and for r in con.execute(...) everywhere. Zipping cursor.description against each tuple closes the gap. Everything else — execute, executescript, commit, lastrowid, ? placeholders, ON CONFLICT — is identical, which is the entire argument for Turso over Postgres.

The schema runs once per process rather than per connection. Locally that saved nothing; on Turso every statement is an HTTP round trip, so it's the difference between a fast tool call and six pointless ones. Cold starts re-run it and CREATE IF NOT EXISTS makes that free.

test_server.py runs its assertions against both backends, so the shim can't rot silently.

Create the database (the schema builds itself on first write):

turso db create foodlog
turso db show foodlog --url      # libsql://foodlog-<you>.turso.io
turso db tokens create foodlog

Free tier is 500 databases, 9 GB and 1B row reads a month. You will use roughly none of it.

Vercel

api/index.py and vercel.json are already in the repo. Two things make it serverless-safe:

  • stateless modestateless_http=True, json_response=True. Sessions and held-open SSE streams assume one long-lived process; serverless has none. Each request stands alone.

  • the secret is in the URL path — ChatGPT connectors can only send "no auth" or full OAuth, so a bearer header isn't available. FOODLOG_PATH=/mcp-<random> is a token in a URL over TLS. Honest assessment: obscurity. Adequate for a food log on a URL you don't paste anywhere; not adequate for anything you'd mind a stranger writing to. Real fix is MCPServer(auth=...) with OAuth.

vercel link
vercel env add TURSO_DATABASE_URL     # libsql://foodlog-<you>.turso.io
vercel env add TURSO_AUTH_TOKEN
vercel env add FOODLOG_PATH           # /mcp-$(openssl rand -hex 12)
vercel deploy --prod

Then point ChatGPT at https://<project>.vercel.app/mcp-<your-random>, and Claude Code at the same URL with claude mcp add --transport http foodlog <url>.

Verify locally first — this is the exact code path Vercel runs:

FOODLOG_PATH=/mcp-test ./.venv/bin/python -m uvicorn api.index:app --port 8123
curl -s -X POST localhost:8123/mcp-test -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Three things bite on Vercel, all of them fixed in api/index.py and vercel.json, none of them obvious from a 404:

The catch-all rewrite replaces the request path. "/(.*)" -> "/api/index" means the ASGI app receives /api/index, not the URL you asked for, so the mount — and the secret inside it — never matches and every path 404s. vercel.json carries the original through as ?__p= and the entrypoint puts it back before the MCP app sees it.

The SDK's DNS-rebinding guard allowlists localhost only, so the deployed hostname comes back 421 Invalid Host header once routing works. The entrypoint allowlists VERCEL_PROJECT_PRODUCTION_URL and VERCEL_URL, which Vercel injects; locally both are unset and the SDK default applies unchanged.

Remote libSQL upper-cases column names that are SQL keywordskey comes back as KEY, where the local engine leaves it alone. That breaks every settings read on the server while passing every test on your laptop. _Cursor folds names to lower case; test_server.py pins that behaviour.

Also: TURSO_AUTH_TOKEN must come from turso db tokens create <db>, not turso auth token. The latter is your account token and the database rejects it with invalid JWT token.

Watch the Python version (pin it in Project Settings → General if the libsql wheel fails to install) and the 10-second function limit on Hobby — vercel.json deliberately doesn't set maxDuration, since asking for more than 10s fails the build there.

Data

Plain SQLite at foodlog.dbsqlite3 foodlog.db "select * from meals" any time, and back it up by copying the file. Times are local and naive; that's correct for a personal food log and wrong the day you move timezones mid-week.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    F
    maintenance
    Enables tracking food intake and nutrition using the USDA FoodData Central database. Supports logging meals, setting daily nutrition goals, viewing food diaries, and analyzing nutrition trends over time with local SQLite storage.
    14
    1
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables querying food nutritional information, discovering recipes by ingredients or diet type, getting ingredient substitutions, and receiving personalized food recommendations based on mood and season.
    -
  • A
    license
    A
    quality
    B
    maintenance
    Enables nutrition tracking with Cronometer, including food logging, food search, diary management, and nutrition data retrieval via natural language.
    13
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to search recipes, compose nutritionally balanced meals, optimize weekly meal plans based on macro targets for family members, and generate consolidated grocery lists from a personal recipe database.
    -

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lucia-urcuyo/food-log-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server