Reference

MCP server

Tally speaks the Model Context Protocol, so an AI agent can read your diary and log food for you. It uses the same API keys as the REST API and the same rules.

Endpoint and auth

  • URL: https://tally.art3m1s.me/api/mcp, Streamable HTTP transport. Requests are POSTs; GET and DELETE answer 405. The server is stateless, so there is no session to keep alive.
  • Auth: Authorization: Bearer tally_live_… on every request. A missing or bad key gets HTTP 401 with a JSON detail and WWW-Authenticate: Bearer.
  • The key decides the tools. A read only key sees the 9 read tools; a read & write key sees all 16. A tool your key does not have is not listed and cannot be called.
  • Tools act as the key's account, with its limits: on a demo account, log_food can run once a day (limits).

Claude Code

Run this once, with your key in place of the placeholder:

terminalbash
claude mcp add --transport http tally https://tally.art3m1s.me/api/mcp --header "Authorization: Bearer tally_live_..."

JSON clients

Clients configured by an mcpServers JSON file take a remote server as a URL plus headers. The file's name and place depend on the client: Claude Code reads .mcp.json in your project, Cursor reads .cursor/mcp.json or ~/.cursor/mcp.json.

mcp.jsonjson
{
  "mcpServers": {
    "tally": {
      "type": "http",
      "url": "https://tally.art3m1s.me/api/mcp",
      "headers": {
        "Authorization": "Bearer tally_live_..."
      }
    }
  }
}

Some clients only start local (stdio) servers; Claude Desktop's config file is one. For those, mcp-remote runs locally and forwards to Tally. The header is written without a space and passed through env because some clients split arguments on spaces:

claude_desktop_config.jsonjson
{
  "mcpServers": {
    "tally": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://tally.art3m1s.me/api/mcp",
        "--header",
        "Authorization:${TALLY_AUTH}"
      ],
      "env": {
        "TALLY_AUTH": "Bearer tally_live_..."
      }
    }
  }
}

Your own agent

Use an MCP SDK's Streamable HTTP client and pass the header with every request. These list the tools and read today's diary.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(new URL("https://tally.art3m1s.me/api/mcp"), {
  requestInit: { headers: { Authorization: `Bearer ${process.env.TALLY_API_KEY}` } },
});
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);

const { tools } = await client.listTools();
console.log(tools.map((t) => t.name));

const today = await client.callTool({ name: "get_day", arguments: {} });
console.log(today.content);

// log_food can take up to about two minutes and the SDK gives up after 60 s:
// await client.callTool({ name: "log_food", arguments: { text: "two eggs" } }, undefined, { timeout: 120_000 });

await client.close();

log_food and retry_lookups wait for Tally's lookups and can take up to about two minutes. Give tool calls a longer timeout than that: the TypeScript SDK stops waiting after 60 seconds unless you pass timeout.

Read tools

Every key has these. They are marked read-only for clients that show it. Arguments with a ? are optional; dates are YYYY-MM-DD in your Tally timezone.

  • whoami()read

    Your email, whether you are the owner, and today's demo quota.

  • get_day(date?)read

    One day's entries by meal and the day's totals against your targets. Default: today.

  • get_history(range = "w")read

    Daily totals, targets and your logging streak. range is "3d", "w" (7 days), "m" (30 days) or "y" (52 weeks, as weekly averages).

  • get_goals()read

    Your targets (null when none are set), body profile and timezone.

  • get_weights(days = 90)read

    Your latest weigh-in and the weigh-ins of the last days days.

  • list_saved_meals(include_inactive = false)read

    Your saved meals and what is in them.

  • search_my_foods(query?, limit = 20)read

    Foods you have logged before, each with the amount you last had. Without a query, the most recent ones.

  • search_foods(query, include_usda = false)read

    Searches Tally's food cache. With include_usda it also searches USDA FoodData Central.

  • get_food(food_id)read

    One food's servings and nutrients, without the raw provider record.

Write tools

Only a read & write key has these.

  • log_food(text, tz?, id?)write

    Stages a log from what you ate, in words, and returns its log_id, the meal, and each row: index, status, food, amount, serving, macros, alternates, and any question. Also the agent's reply and options. Rows are staged, not saved: call commit_log to save them. tz defaults to the timezone in your settings.

    id is any UUID you pick. If a call times out, call again with the same id: you get the rows the first call has stored so far instead of a second log. That answer has no reply or options, and while the first call is still running its rows may be incomplete, so try again shortly. A row left searching ran out of time for its lookup; retry_lookups runs it again.

  • commit_log(log_id, drop_items?, save_as_meal?)write

    Saves a staged log's rows to the diary. drop_items lists row indexes to leave out; save_as_meal also saves the rows as a saved meal under that name.

  • retry_lookups(log_id)write

    Looks up again, one after another, each row of a log that is still searching, and returns the log's rows in log_food's shape. On a staged log, commit_log then saves the rows that resolved. On a log already committed, this call saves each row it resolves to the diary itself. The rows share one request's time, up to about two minutes; a row there was no time for stays searching, so call again. A discarded log is refused. On a demo account each lookup counts toward the 10 a day (limits).

  • discard_log(log_id)write

    Throws a staged log away.

  • edit_entry(entry_id, qty?, serving_id?, food_id?)write

    Changes a diary entry's amount, serving or food.

  • delete_entry(entry_id)write · destructive

    Deletes a diary entry for good. It is marked destructive, so your client can ask you before it runs.

  • log_weight(weight, unit: "kg" | "lb", day?)write

    Records a weigh-in; one per day, so a second one replaces the first.

How logging works

Logging over MCP is the app's own flow. log_food runs Tally's logging agent on your words. The agent names the foods and amounts; the calories and macros come from a food record, such as USDA FoodData Central or a restaurant's own published nutrition page, cited with a link. A model never makes the numbers up, and a food Tally cannot find comes back not_found instead of a guess.

Nothing is in your diary until commit_log. That leaves room for your agent to show you the rows and ask before saving. Only rows with status resolved are saved; a row still searching or waiting on a question is left uncommitted. retry_lookups looks a searching row up again, before the commit or after it. Photos are not available over MCP.

Example prompts

  • “What have I eaten today, and how much protein do I have left?”
  • “How did this week go against my calorie target?”
  • “Log two eggs and a fairlife for breakfast. Show me the rows before you save them.”
  • “I weighed 180.4 lb this morning.”
  • “Change the rice at lunch to one cup.”