tandoor-mcp
# tandoor-mcp
An MCP server for [Tandoor Recipes](https://tandoor.dev), built for creating and
editing recipes from the Claude app.
## Setup
```bash
uv sync
cp .env.example .env # fill in TANDOOR_URL and TANDOOR_TOKEN
uv run tandoor-mcp
```
The API token comes from Tandoor under *Settings → API → Access Tokens*.
## Docker
```bash
export TANDOOR_URL=... TANDOOR_TOKEN=... \
TANDOOR_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
docker compose up -d
```
Alpine-based, ~136 MB. The container defaults to the HTTP transport bound to
`0.0.0.0`, since stdio has nothing to talk to inside a container.
Two things bite when connecting to a containerised Tandoor:
- **ALLOWED_HOSTS.** Tandoor answers a request whose `Host` header it does not
recognise with a bare `Bad Request (400)` and no explanation. The hostname in
`TANDOOR_URL` must be listed in Tandoor's `ALLOWED_HOSTS`.
- **Reachability.** Either attach to Tandoor's compose network and use the
service name, or reach the host — both need the hostname allowed above.
## Transport
`TANDOOR_TRANSPORT=stdio` (default) for local clients, or `http` with
`TANDOOR_HOST` / `TANDOOR_PORT` to expose it over the network. HTTP is served
at `/mcp`, or wherever `TANDOOR_PATH` points.
`TANDOOR_PATH` exists for reverse proxies that route by prefix without
stripping it — the server then has to answer on the same path the client asks
for, e.g. `TANDOOR_PATH=/tandoor/mcp` behind
`PathPrefix(/tandoor)`. Like `TANDOOR_HOST` and `TANDOOR_PORT` it describes
this server, not the Tandoor instance.
The HTTP transport requires `TANDOOR_AUTH_TOKEN`; clients send it as
`Authorization: Bearer <token>` and anything else gets a 401. The server
refuses to start without it, because an open port hands write access to the
recipe database to whoever can reach it. `TANDOOR_ALLOW_UNAUTHENTICATED=true`
waives this deliberately. stdio needs no token — the transport is a pipe to a
process the user started.
For Claude Desktop with stdio:
```json
{
"mcpServers": {
"tandoor": {
"command": "uv",
"args": ["run", "--directory", "/path/to/tandoor-mcp", "tandoor-mcp"],
"env": {
"TANDOOR_URL": "https://tandoor.example.com",
"TANDOOR_TOKEN": "tda_..."
}
}
}
}
```
## Tools
| Tool | Purpose |
| --- | --- |
| `search_recipes` | Compact hits, with Tandoor's full filter set |
| `get_recipe` | One recipe in full, with all ids |
| `create_recipe` | Whole recipe incl. steps and ingredients |
| `update_recipe` | Read-modify-write update |
| `delete_recipe` | Delete, including its parts |
| `log_cooked` | Record that a recipe was cooked |
| `search_*` | Look up entity ids, many terms per call, scored |
| `create_*` | Add entities deliberately, several at a time |
| `update_*` | Fix an entity in place |
| `delete_*` | Remove entities, guarded |
| `merge_*` | Fold a duplicate into the entry that stays |
| `move_food` / `move_keyword` | Nest an entity into a tree |
`*` is `foods`, `units`, `keywords` and `food_categories`.
Search hits are id, name and a match score. Everything else — a food's category
or plural name, a keyword's position in the tree — is opt-in per call via
`options`, because a batch of twenty ingredients multiplies whatever a single
hit costs and the model is usually only picking an id.
`search_recipes` exposes Tandoor's own filters rather than a subset:
`keywords_and` / `_or` / `_and_not` / `_or_not` and the same four for foods,
plus rating, cooking history (`cookedon_lte`, `timescooked_gte`), `makenow` and
`sort_order`. The four flavours matter — Tandoor's plain `keywords` parameter is
an OR, so a tool that describes it as "must carry all of them" promises
something the API does not deliver.
**Keywords and foods are both trees, and filtering follows them.** A recipe
tagged only `Vorspeise` is found by filtering on its parent `Gang`; a recipe
using only `Büffelmozzarella` is found by filtering on `Käse`. Both verified
against a live instance, and `include_children=false` turns it off.
That makes specificity free: referencing the exact food costs nothing in
findability, as long as it hangs in the right place. It is also the answer to a
database that grew flat — `move_food` re-parents without touching any recipe.
Tandoor ignores `parent` in a create payload (the response comes back with
parent null), so creation happens in parallel and nesting afterwards, strictly
one at a time: each move rewrites the whole nested-set tree, and two at once
answer 500.
## Layout
```
src/tandoor_mcp/
├── config.py # TANDOOR_* settings
├── client.py # httpx wrapper, DRF errors -> readable messages
├── types.py # shared JSON alias
├── templating.py # step-templating reference text (see below)
├── payloads.py # input -> Tandoor, read-only stripping, response slimming
├── server.py # assembly; iterates tools.DOMAINS
├── models/ # entities.py, recipes.py — tool input validation
└── tools/ # one module per domain, each exposing register(mcp, client)
├── _entity_tools.py # shared search and delete machinery
├── recipes.py
├── foods.py
├── units.py
├── keywords.py
└── categories.py
```
`templating.py` holds no logic. `TEMPLATING_REFERENCE` is a plain string that
gets interpolated into the `description=` of `create_recipe` and
`update_recipe`, so it becomes part of what the model reads when it lists the
tools. Editing that one constant changes the guidance everywhere it appears.
The four entity domains differ only in endpoint and wording, so their search and
delete tools are built once in `_entity_tools.py` and registered per domain via
`EntitySpec` and `MCPServer.add_tool`, which takes the tool name and
description as arguments. Each spec lists the `options` its domain supports, so
every search tool advertises exactly its own enum.
## Requires fuzzy lookup
Tandoor only uses trigram similarity for food/unit/keyword search when the
calling user has `lookup` enabled in their search preferences
(`cookbook/views/api.py`, `FuzzyFilterMixin`). Without it the search falls back
to a plain substring match, and `Kartoffeln` then fails to find `Kartoffel`
while happily returning `Süßkartoffelnudel`.
```bash
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$TANDOOR_URL/api/search-preference/<user-id>/" -d '{"lookup": true}'
```
## Design notes
**Entities are referenced by id, never by name.** Tandoor's serializers accept a
nested food or unit without an id and create a new one, which is how duplicate
ingredients accumulate. `EntityRef` in `models.py` makes the id mandatory, so a
name-only reference fails validation instead of silently polluting the database.
Tandoor additionally requires `name` next to `id` on nested entities — id alone
is a 400.
**No ingredient parser.** Tandoor's `/api/ingredient-parser/` splits a written
line well, but it *creates* every food and unit it fails to recognise. Measured
on 20 realistic German lines it silently added four entities — `geriebener
Parmesan` next to the existing `Parmesan`, `rote` as a unit. Since fuzzy search
resolves the same lines without writing anything, the parser is not exposed.
**Search is batched.** One call takes every ingredient of a recipe, one request
per term in parallel — 12 terms in ~230 ms against a local instance. Ranking is
left entirely to Tandoor.
**Search hits are scored, because Tandoor has no "not found".** Its fuzzy lookup
annotates by trigram similarity and orders by it, but never filters, so every
query returns a full page whatever was asked: `Xylophon` comes back with Bacon,
Honig and Natron, and `Mozzarella` with Mehl and Mandel behind the real hit.
Each hit therefore carries a `score` — the returned name measured against the
query, singular and plural — and anything under `min_score` is dropped, so an
empty result finally means something. The default of 0.55 is calibrated on that
noise: it removes Mehl (0.43) and Mandel (0.50) from `Mozzarella` while keeping
Süßkartoffel (0.83) and Karotte (0.67) for `Kartoffeln`. The ordering is still
Tandoor's; this only says how far down the list stopped being relevant.
**Out-of-range template indexes are rejected client-side.** Tandoor renders
`{{ ingredients[9] }}` as an empty string with no error, so the mistake would
only surface as a missing word in the finished recipe. `StepInput` validates
every index against the step's own ingredient count.
**Updates are read-modify-write.** Tandoor treats the `steps` array as the whole
truth: omitted steps get deleted, id-less ones get created. `update_recipe`
therefore fetches the recipe, strips the read-only fields, merges the requested
changes and writes the complete object back.
Steps and ingredients keep their ids through an update, which is what makes it
an edit rather than a rebuild. `StepInput` and `IngredientInput` carry an
optional `id` and pass it through; without it Tandoor discards every step and
creates replacements, so one update of a two-step recipe left two dead step
rows behind and renumbered everything.
Removing a step is the other half of that. Tandoor detaches it from the recipe
without deleting the row, and `/api/step/<id>/` answers 404 the moment it is
detached, so nothing can ever clear it up afterwards. `update_recipe` deletes
what the new step list drops *before* writing the update, while those rows are
still reachable.
**New foods must decide on a plural and a category.** Both are required
parameters of `create_food` with no default, though either may be null. A food
without a plural reads wrong at every amount other than one, and one without a
category falls to the bottom of every shopping list — and neither is visible
once the food is in place.
**Deleting a food is guarded.** Tandoor deletes a food that recipes still use
with a plain 204 and cascades to their ingredient lines: verified on 2.6, a
food used by one recipe deleted cleanly and left that recipe's step with zero
ingredients. `delete_food` therefore asks `/api/recipe/?foods=<id>` first and
refuses while anything uses it, naming the recipes. `delete_keyword` does the
same. Units are not guarded — deleting one only sets the field to null — but
that too happens across every recipe at once, so the tool says so.
The dependency check deliberately ignores Tandoor's own `cascading` entries for
ingredients and steps. Deleting a recipe orphans both: after deleting the only
recipe in a fresh instance, the database still held its 4 steps and 3
ingredients, unreachable through any endpoint and still claiming their food was
in use. `delete_recipe` therefore takes a recipe apart from the inside out —
ingredients, then steps, then the recipe — and leaves nothing behind.
**Merging is the repair that deleting is not.** `merge_foods(source, target)`
repoints every recipe from the source to the target and deletes the source, so
a duplicate disappears without any recipe losing an ingredient. It is guarded
like deletion, and for one reason more than usual: Tandoor keeps the target
*exactly* as it is, so anything only the source knew is discarded silently.
Verified on 2.6 — merging a food that carried a category and a description into
one that had neither left the target with neither. The refusal therefore lists
both the recipes that will move and the fields that will be lost, and says which
direction the merge should run: the target is the better-maintained entry, not
the better-named one. Whatever was lost can be put back with `update_food`,
which is why the tool does not try to carry it over itself.
**Responses are slimmed.** A fully expanded recipe carries nutrition, properties,
conversions and user objects. `payloads.py` reduces both search hits and full
recipes to what the model can actually act on.
**Templating is verifiable.** For templated steps the write tools return
`instruction_rendered`: Tandoor's own rendering, collapsed from its Vue markup
back to plain text ("200 EL Mehl mit 2 Zehen Knoblauch verrühren"). The syntax
reference lives in `templating.py`, verified against Tandoor 2.6.13.
## Development
```bash
uv run ruff check .
uv run ruff format .
uv run ty check
```
### A Tandoor to develop against
```bash
docker compose -f dev/docker-compose.yml up -d
dev/bootstrap.sh # user, space, search preferences, API token -> .env.dev
uv run python dev/seed.py # ~120 German foods, 8 categories, units, keywords
```
`dev/bootstrap.sh` writes `.env.dev`; both it and `dev/data/` are gitignored.
Data survives `docker compose down` and disappears only with `dev/data/`. The
web UI is on <http://localhost:8090>, login `dev` / `dev`.
Postgres rather than SQLite on purpose: fuzzy entity lookup is trigram
similarity from `pg_trgm`, so on SQLite the search tools would quietly behave
like a substring match and anything measured against it would be worthless. A
fresh Tandoor also has an empty food table, which is why the seed exists —
ranking cannot be judged against nothing.
TDQS
Scored across 11 tools
Each tool targets a distinct resource and action: recipe CRUD is split into search/get/create/update/delete, and the supporting vocabularies (foods, units, keywords) each have separate search and create tools. There is no overlap or ambiguity between tool purposes.
Tool names follow a consistent verb_noun pattern, with search verbs using plural entities (search_recipes, search_foods) and other verbs using singular (get_recipe, create_food). This is a predictable and readable convention throughout the set.
11 tools is well-scoped for a recipe management server: full CRUD for recipes plus search/create for the three reference vocabularies. Each tool earns its place without redundancy or bloat.
Recipe lifecycle is complete (search, get, create, update, delete). Supporting entities have search and create but no update or delete, which is a minor gap since erroneous food/unit/keyword entries cannot be corrected via the API. Core recipe workflows are fully covered.