Skip to main content
Glama

tandoor-mcp

An MCP server for Tandoor Recipes, built for creating and editing recipes from the Claude app.

Setup

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.

Related MCP server: Tandoor MCP Server

Docker

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:

{
  "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.

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

uv run ruff check .
uv run ruff format .
uv run ty check

A Tandoor to develop against

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.

Available Tools

11 tools
create_foodA

Create a new food (ingredient). Search first. Tandoor has no duplicate detection, so a second 'Zwiebel' would sit next to the existing one forever with nothing to flag it. Store the plain noun ('Parmesan'), never a prepared form ('geriebener Parmesan') — preparation belongs in the ingredient's note.

Inputs: name (required), description, plural_name. Returns: result

create_keywordA

Create a new keyword (tag). Search first. Tandoor has no duplicate detection, so a second 'Vegan' would sit next to the existing one forever with nothing to flag it.

Inputs: name (required), description. Returns: result

create_recipeA

Create a new recipe with all its steps and ingredients in one call.

Every food, unit and keyword must be referenced by id and name, looked up with search_foods / search_units / search_keywords beforehand. Reduce each written ingredient line to a singular base noun for the search and put adjectives and preparation into the ingredient's note: '100 g geriebener Parmesan' becomes food 'Parmesan', unit 'g', amount 100, note 'gerieben'.

STEP TEMPLATING Instructions are Jinja templates. Placeholders are replaced when the recipe is viewed, and amounts inside them rescale automatically when the user changes the serving count. Literal numbers you type do not rescale.

{{ ingredients[0] }} amount + unit + food, plural-aware ("2 Chilischoten", "30 Gramm Ingwerwurzel", "2 Zehen Knoblauch") {{ ingredients[0].food }} just the food name, plural-aware {{ ingredients[0].amount }} just the number, scaling {{ ingredients[0].unit }} just the unit {{ ingredients[0].note }} the note; notes are NOT part of {{ ingredients[0] }} {{ scale(200) }} any other number that should scale with servings

The index is ZERO-BASED and refers to the ingredient list of the SAME step, in the order you supply it. Indexes are not shared across steps.

CRITICAL: an out-of-range index renders as an empty string, silently — the sentence simply loses a word and nothing reports an error. Count the step's own ingredients before writing an index. This server rejects out-of-range indexes, so a rejection means your index was wrong, not that the syntax was.

Convention worth following: annotate each reference with a Jinja comment so the template stays readable, e.g. {{ ingredients[1] }}{# Ingwerwurzel #} schälen und grob würfeln. Comments render to nothing.

Write prose and put a placeholder wherever an amount or an ingredient name occurs, instead of repeating the numbers literally.

Inputs: recipe (required). Returns: result

create_unitA

Create a new unit of measure. Search first. Tandoor has no duplicate detection, so a second 'Gramm' would sit next to the existing one forever with nothing to flag it. List the units first: the vocabulary is short and the one you want almost certainly exists.

Inputs: name (required), description, plural_name. Returns: result

delete_recipeA

Permanently delete a recipe. This cannot be undone.

Inputs: recipe_id (required). Returns: result

get_recipeA

Read one recipe in full: steps, ingredients, and all entity ids.

Always call this before update_recipe. The ids in the result are what you pass back to keep existing steps, ingredients, foods and units intact.

Inputs: recipe_id (required). Returns: result

search_foodsA

Look up foods (ingredients) by name — all of a recipe's ingredients at once.

Results are ranked by similarity, best first, so the top hit is usually right but the tail may be unrelated. Pick the entry that means the ingredient itself: prefer 'Parmesan' over 'Parmesankäse' unless the recipe really calls for the latter.

Search for the ingredient, not the whole written line. Amount, unit and preparation are separate fields, so "100 g geriebener Parmesan" is one query for 'Parmesan', with 'gerieben' going into the ingredient's note.

Foods form a tree, so 'full_name' shows the path ('Gemüse > Zwiebel'). Called without queries this lists foods, which is rarely useful — the database holds thousands.

Inputs: limit, queries. Returns: result

search_keywordsA

Look up keywords (tags) by name, or list them all.

Keywords form a tree like foods, so 'full_name' shows the path. Called without queries this lists the existing tags, which is a good way to match a recipe to the vocabulary already in use rather than inventing a parallel one.

Inputs: limit, queries. Returns: result

search_recipesA

Search recipes. Returns compact hits (id, name, description, keywords).

Use get_recipe with an id from the result to read steps and ingredients.

Inputs: new, page, foods, query, +4 more. Returns: result

search_unitsA

Look up units of measure, or list them all.

Called without queries this returns the complete vocabulary, which is short. Do that when unsure rather than guessing a name: Tandoor keeps 'g' and 'Gramm' as separate units, and picking the wrong one is invisible afterwards.

Inputs: limit, queries. Returns: result

update_recipeA

Update an existing recipe. Only the fields you set are changed; the rest of the recipe is read and written back unchanged.

Passing 'steps' replaces the entire step list. Call get_recipe first and pass back every step you want to keep, including its id — a step without an id is created as new, and an omitted step is deleted. The same holds for the ingredients inside a step.

STEP TEMPLATING Instructions are Jinja templates. Placeholders are replaced when the recipe is viewed, and amounts inside them rescale automatically when the user changes the serving count. Literal numbers you type do not rescale.

{{ ingredients[0] }} amount + unit + food, plural-aware ("2 Chilischoten", "30 Gramm Ingwerwurzel", "2 Zehen Knoblauch") {{ ingredients[0].food }} just the food name, plural-aware {{ ingredients[0].amount }} just the number, scaling {{ ingredients[0].unit }} just the unit {{ ingredients[0].note }} the note; notes are NOT part of {{ ingredients[0] }} {{ scale(200) }} any other number that should scale with servings

The index is ZERO-BASED and refers to the ingredient list of the SAME step, in the order you supply it. Indexes are not shared across steps.

CRITICAL: an out-of-range index renders as an empty string, silently — the sentence simply loses a word and nothing reports an error. Count the step's own ingredients before writing an index. This server rejects out-of-range indexes, so a rejection means your index was wrong, not that the syntax was.

Convention worth following: annotate each reference with a Jinja comment so the template stays readable, e.g. {{ ingredients[1] }}{# Ingwerwurzel #} schälen und grob würfeln. Comments render to nothing.

Write prose and put a placeholder wherever an amount or an ingredient name occurs, instead of repeating the numbers literally.

Inputs: update (required), recipe_id (required). Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.4/5.0

    Scored across 11 tools

    Disambiguation5/5

    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.

    Naming Consistency5/5

    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.

    Tool Count5/5

    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.

    Completeness4/5

    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.

    Maintenance

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      MCP server for MealMastery AI meal planning that enables users to manage meal plans, recipes, and grocery lists through natural language conversation with AI agents like Claude.
      57 npm
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      An MCP server for Mealie that provides curated tools for recipes, meal plans, and cookbooks with compact responses, enabling natural language management of your Mealie instance.
      1
      MIT