tandoor-mcp
MCP server for Tandoor Recipes, enabling recipe management (create, edit, search, delete) and entity operations (foods, units, keywords) via natural language from Claude.
README
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.
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
Hostheader it does not recognise with a bareBad Request (400)and no explanation. The hostname inTANDOOR_URLmust be listed in Tandoor'sALLOWED_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 search hits |
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 |
search_foods / search_units / search_keywords |
Look up entity ids, many terms per call |
create_food / create_unit / create_keyword |
Add entities deliberately |
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 machinery for the three entity domains
├── recipes.py
├── foods.py
├── units.py
└── keywords.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 three entity domains differ only in endpoint and wording, so their search
tool is 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.
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; the server adds no matching logic of its own.
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.
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
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
Neon Database
MCP server for interacting with Neon Management API and databases
E2B
Using MCP to run code via e2b.
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.