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 answer405. 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 HTTP401with a JSONdetailandWWW-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_foodcan run once a day (limits).
Claude Code
Run this once, with your key in place of the placeholder:
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.
{
"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:
{
"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();
# pip install "mcp>=2.2,<3"
import asyncio
import os
import httpx2
from mcp.client import Client
from mcp.client.streamable_http import streamable_http_client
URL = "https://tally.art3m1s.me/api/mcp"
HEADERS = {"Authorization": f"Bearer {os.environ['TALLY_API_KEY']}"}
async def main() -> None:
# log_food can take up to about two minutes, so allow long reads
timeout = httpx2.Timeout(30, read=150)
async with httpx2.AsyncClient(headers=HEADERS, timeout=timeout) as http:
async with Client(streamable_http_client(URL, http_client=http)) as client:
listed = await client.list_tools()
print([t.name for t in listed.tools])
today = await client.call_tool("get_day", {})
print(today.content)
asyncio.run(main())
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()readYour email, whether you are the owner, and today's demo quota.
get_day(date?)readOne day's entries by meal and the day's totals against your targets. Default: today.
get_history(range = "w")readDaily totals, targets and your logging streak.
rangeis"3d","w"(7 days),"m"(30 days) or"y"(52 weeks, as weekly averages).get_goals()readYour targets (
nullwhen none are set), body profile and timezone.get_weights(days = 90)readYour latest weigh-in and the weigh-ins of the last
daysdays.list_saved_meals(include_inactive = false)readYour saved meals and what is in them.
search_my_foods(query?, limit = 20)readFoods you have logged before, each with the amount you last had. Without a
query, the most recent ones.search_foods(query, include_usda = false)readSearches Tally's food cache. With
include_usdait also searches USDA FoodData Central.get_food(food_id)readOne food's servings and nutrients, without the raw provider record.
Write tools
Only a read & write key has these.
log_food(text, tz?, id?)writeStages 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: callcommit_logto save them.tzdefaults to the timezone in your settings.idis any UUID you pick. If a call times out, call again with the sameid: 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 leftsearchingran out of time for its lookup;retry_lookupsruns it again.commit_log(log_id, drop_items?, save_as_meal?)writeSaves a staged log's rows to the diary.
drop_itemslists row indexes to leave out;save_as_mealalso saves the rows as a saved meal under that name.retry_lookups(log_id)writeLooks up again, one after another, each row of a log that is still
searching, and returns the log's rows inlog_food's shape. On a staged log,commit_logthen 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 stayssearching, 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)writeThrows a staged log away.
edit_entry(entry_id, qty?, serving_id?, food_id?)writeChanges a diary entry's amount, serving or food.
delete_entry(entry_id)write · destructiveDeletes 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?)writeRecords 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.”