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
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。