# CalorieLog Pro MCP connector

CalorieLog Pro runs an [MCP](https://modelcontextprotocol.io) server so an AI assistant
(Claude via a custom connector) can read and log your meals without opening the app.

- Transport: Streamable HTTP (stateless, JSON responses), via the official `mcp` Python SDK.
- URL: `https://calorielogpro.com/mcp/<token>`, or `https://calorielogpro.com/mcp` with an
  `Authorization: Bearer <token>` header for clients that can send headers.
- Code: `mcp_server.py` (tokens, gateway, tools). All logging goes through the `svc_*`
  service functions in `calorie.py`, the same code the app's own routes use.

## Connect (OAuth, recommended)

1. In Claude, open **Settings → Connectors → Add custom connector**.
2. Name it "CalorieLog Pro", URL `https://calorielogpro.com/mcp`, leave the OAuth client fields
   empty, and save.
3. Click **Connect**. Claude opens CalorieLog Pro's consent screen. Sign in with Google (this
   creates a free account on first use), check what it may do, and click **Allow**.

ChatGPT (developer mode) and other MCP clients follow the same flow.

How it works:
- `POST /mcp` without a token returns 401 with
  `WWW-Authenticate: Bearer resource_metadata="https://calorielogpro.com/.well-known/oauth-protected-resource/mcp"`.
- The client reads that document (RFC 9728), then `/.well-known/oauth-authorization-server`
  (RFC 8414). It registers itself at `/register` (dynamic client registration), then runs
  `/authorize` with PKCE S256, which leads to `/oauth/consent`, and finishes at `/token`.
- Access tokens (`clpa_…`) last 1 hour. Refresh tokens (`clpr_…`) last 90 days and rotate on
  every use. Only their sha256 hashes are stored.
- Scopes are `read` and `write`. Read-only tools need `read`, the rest need `write`.
- **Assistant → Connected AI apps** lists each app with when it was connected and last used.
  **Disconnect** revokes all of that app's tokens. `/revoke` (RFC 7009) works too.
- The protocol endpoints come from the official MCP SDK (`mcp.server.auth`). Storage and the
  consent screen are in `oauth.py`.
- After **Allow**, the consent screen returns a page that navigates to the client itself
  rather than sending a 303. The shared nginx CSP has `form-action 'self'`, and Chrome applies
  that to redirects that follow a form POST.

## Secret link (fallback for clients that can't do OAuth)

1. Sign in at https://calorielogpro.com.
2. Open **Assistant → Advanced: secret links**, enter a label and tap **Create link**.
3. Copy the URL straight away (`https://calorielogpro.com/mcp/clp_…`). It's shown only once,
   and only its hash is stored.

Anyone who has that URL can read and log your meals. **Disconnect** revokes it, and a revoked
link gets 401. Each account can have up to 10 links. `Authorization: Bearer clp_…` at `/mcp`
works too.

## Tools

Dates are `YYYY-MM-DD` and times `HH:MM` (24h), both in the user's timezone
(`cal_settings.timezone`, defaulting to `America/Toronto`). `date` defaults to today there.
Meals are `breakfast`, `lunch`, `dinner` or `snack`; any other value is an error that lists
those four.

**Times:** leave `time` out when it isn't known. The entry is then *untimed*
(`time_approx=1`): it sorts at its meal's slot (08:00, 12:00, 18:00, snack 15:00), the app
shows no time, and tools return `"time": null`. `"now"` means the current time and only
works for today. Entries logged in the app itself still get a real time.

The tools are designed for **photo-to-batch logging**: a photo of a meal, a receipt or a
handwritten day becomes one `log_entries` call, vague lines become `needs_detail`
placeholders, and the user's answers become one `update_entries` call. Abbreviations the
user explains are saved with `save_food` aliases, so the next photo needs no questions.

| Tool | Arguments | Returns |
|---|---|---|
| `get_day` | `date?` | goal, total, remaining, `macros` (protein/carbs/fat/fiber totals), `to_refine` count, and entries `{id, label, kcal, time, amount?, protein_g?, carbs_g?, fat_g?, note?, needs_detail?}` grouped by meal |
| `log_entries` | `date?`, `entries` (max 50, see below) | created entries with ids, `day_total`, `remaining`, `to_refine` |
| `search_foods` | `query` (a term **or a list of terms**), `limit?` (default 10, max 50) | per term: `foods` (matched by name or alias: `food_id`, `name`, `unit`, `kcal_per_g`/`kcal_per_ml`, `log_units`, `serving`, `kcal_per_serving`, `aliases`, `macros_per_100g`/`ml`), `recipes` (`recipe_id`, `name`, `kcal_per_serving`, `servings`, `yield`), and `past_entries` (`label`, `times`, `avg_kcal`, `last_kcal`, `last_date`) from the user's diary. A single string query returns one result object; a list returns an object keyed by term |
| `update_entries` | `updates: [{id, label?, kcal?, meal?, date?, time?, note?, needs_detail?, protein_g?…}]` (max 50) | updated entries and totals for each affected day |
| `delete_entries` | `ids` (max 50) | `deleted`, `not_found`, totals for each affected day |
| `get_entries_to_refine` | `days?` (default 30, max 365) | entries still flagged `needs_detail`, newest day first |
| `save_food` | `name`, or `food_id` to edit; `aliases?`, `unit?` (`g`/`ml`), `kcal_per_100` **or** `kcal_per_serving` + `serving_size` (+ `serving_label`), macros | the saved food |
| `delete_food` | `food_id` (the user's own foods only) | `{deleted}`. Logged entries keep their values |
| `get_range` | `start`, `end` (at most 366 days) | per-day totals, average per logged day, days logged, days at or under the goal, and the current streak |
| `get_goal` / `set_goal` | `kcal` (500–20000) | `{goal}` |
| `submit_feedback` | `text`, `category` (`feature_request`, `bug` or `other`) | `{received, id}`. Stored in `mcp_feedback`, sent to Discord, at most 20 per user per day |

Every tool has a `title` and `readOnlyHint`, `destructiveHint`, `idempotentHint` and
`openWorldHint=false` annotations. `update_entries`, `delete_entries`, `save_food`,
`delete_food` and `set_goal` are marked destructive.

If `log_entries` is called again with identical arguments within 60 seconds, it returns the
first result instead of logging twice. This catches client retries. It never merges
repeated items *inside* one call (three "Chips 70" lines stay three entries). Any other
write by the same user clears this guard.

`log_entries` entries are one of:
- `{label, kcal, meal}`: `kcal` is a whole number from 0 to 5000 (the assistant's estimate).
- `{food_id, meal, quantity?, unit?, servings?}`: a saved food. `quantity` is in one of its
  `log_units` (`g`/`ml`, `tsp`/`tbsp`/`cup` for ml foods, or `serving`). With no quantity, a
  food with a serving size logs 1 serving. `label` optionally overrides the name.
- `{recipe_id, meal, servings?}` or `{recipe_id, meal, quantity, unit}` in the recipe's yield
  unit; 1 serving by default.

Optional on any entry: `time`, `note` (up to 300 chars), `needs_detail` (bool), and nutrients
as flat keys (`protein_g`, `carbs_g`, `fat_g`, `fiber_g`, `sugar_g`, `sodium_mg`, or any
other key in `NUTRIENT_KEYS`). Unknown fields are an error. The whole call is validated
before anything is written: one bad entry means nothing is logged. Saved-food and recipe
math is identical to logging in the app (per-100 scaling; `recipePortion()` rounding).

`update_entries` rules:
- Only the fields passed change.
- `time: null` makes an entry untimed; `note: null` clears the note.
- Changing `label` or `kcal` clears `needs_detail` unless `needs_detail` is passed.
- When `kcal` changes, stored macros that aren't passed are scaled by new/old kcal.
- All-or-nothing: an unknown id fails the whole call.

The app shows `needs_detail` entries as a "N entries to refine" card on the Today view
(last 30 days, `GET /api/calorie/refine`) and a "to refine" badge. Tapping one opens a
small form; Save or "Looks right" clears the flag.

### Example `log_entries` call

```json
{
  "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": {
    "name": "log_entries",
    "arguments": {
      "date": "2026-10-25",
      "entries": [
        {"label": "Oatmeal with blueberries", "kcal": 320, "meal": "breakfast", "time": "07:30",
         "protein_g": 10, "carbs_g": 55, "fat_g": 6},
        {"label": "Souper", "kcal": 470, "meal": "dinner", "needs_detail": true,
         "note": "What was supper?"}
      ]
    }
  }
}
```

```sh
curl -s https://calorielogpro.com/mcp/$TOKEN \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d @call.json
```

The result text is compact JSON:

```json
{"date":"2026-10-25","created":[{"id":812,"label":"Oatmeal with blueberries","kcal":320,"time":"07:30","protein_g":10,"carbs_g":55,"fat_g":6,"meal":"breakfast"},{"id":813,"label":"Souper","kcal":470,"time":null,"note":"What was supper?","needs_detail":true,"meal":"dinner"}],"day_total":790,"remaining":1210,"to_refine":1}
```

## Security

- **Origin:** a request that sends an `Origin` header outside the allowlist gets 403. The
  allowlist is the app itself, claude.ai, claude.com, chatgpt.com and chat.openai.com, plus
  anything in `CLP_MCP_ORIGINS`. Server-side clients send no Origin and are allowed.
- **Analytics:** every tool call is recorded in `mcp_calls` with the user, whether the auth was
  OAuth or a link, the client name, the tool, success, latency and any error. No arguments are
  stored.
- **Tokens:** 32 random bytes (`clp_` + urlsafe base64). The database stores only the
  sha256 hash, looked up by hash and then checked with `hmac.compare_digest`.
- **Scoping:** every tool works only on the token's `user_id`. Another user's entries,
  foods and recipes come back as "not found". The tests prove it.
- **Rate limit:** 60 requests per minute per token, in memory, answered with 429 and
  `Retry-After: 60`. nginx's `api_limit` zone applies too.
- **Errors and usage tracking:**
  - A missing, unknown or revoked token gets 401.
  - `last_used_at` is updated on every authenticated request.
- **The token is kept out of logs:**
  - The gateway rewrites the request path to `/mcp` before the app or uvicorn's access
    log can see it.
  - nginx keeps no access log for `/mcp`.
  - nginx's *error* log can still include the request line if the upstream is down.
- **Separation from the browser app:**
  - `/mcp` is handled by an ASGI middleware (`McpGateway`) outside the session-cookie
    routes.
  - It sets no CORS headers.

## Tests

```sh
venv/bin/python -m pytest tests -q
```

The suite covers every tool: happy paths, validation, bad or revoked tokens, the rate
limit, token scrubbing, cross-user isolation (reads, deletes, foods, recipes, goal), parity
with the app's own `/api/calorie/entry`, and the browser routes after the service-layer
refactor.
