chefkoch-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chefkoch_mcp-0.1.0/LICENSE +21 -0
- chefkoch_mcp-0.1.0/PKG-INFO +91 -0
- chefkoch_mcp-0.1.0/README.md +69 -0
- chefkoch_mcp-0.1.0/pyproject.toml +43 -0
- chefkoch_mcp-0.1.0/setup.cfg +4 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp/__init__.py +5 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp/__main__.py +8 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp/server.py +350 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/PKG-INFO +91 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/SOURCES.txt +13 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/dependency_links.txt +1 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/entry_points.txt +2 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/requires.txt +2 -0
- chefkoch_mcp-0.1.0/src/chefkoch_mcp.egg-info/top_level.txt +1 -0
- chefkoch_mcp-0.1.0/tests/test_server.py +269 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smlfg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chefkoch-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP-Server für Chefkoch.de: Rezepte suchen, anzeigen und Einkaufslisten erstellen
|
|
5
|
+
Author-email: smlfg <186922365+smlfg@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/smlfg/chefkoch-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/smlfg/chefkoch-mcp
|
|
9
|
+
Keywords: chefkoch,recipes,mcp,model-context-protocol
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: mcp<2,>=1.20
|
|
20
|
+
Requires-Dist: chefkoch-cli>=0.1.1
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# chefkoch-mcp
|
|
24
|
+
|
|
25
|
+
MCP-Server für Chefkoch.de: Rezepte suchen, Rezeptdetails anzeigen und
|
|
26
|
+
Einkaufslisten aus mehreren Rezepten erstellen. Ausschließlich lesend, ohne
|
|
27
|
+
Login.
|
|
28
|
+
|
|
29
|
+
Der Server ist eine dünne Hülle um den Kern der
|
|
30
|
+
[`chefkoch-cli`](https://github.com/smlfg/chefkoch-cli): Er importiert
|
|
31
|
+
`chefkoch_cli.service` und `chefkoch_cli.client` und ruft dieselben Funktionen
|
|
32
|
+
wie `chefkoch suche`, `chefkoch rezept` und `chefkoch einkaufsliste` auf.
|
|
33
|
+
|
|
34
|
+
## Was es kann
|
|
35
|
+
|
|
36
|
+
- **Rezepte suchen** mit Filtern für vegetarisch/vegan, maximale Zeit,
|
|
37
|
+
Mindestbewertung, Schwierigkeit, Sortierung und Seite.
|
|
38
|
+
- **Rezeptdetails** per ID, Chefkoch-URL oder Suchbegriff, Zutaten auf
|
|
39
|
+
Wunschportionen skaliert.
|
|
40
|
+
- **Einkaufsliste** aus mehreren Rezepten, Zutaten nach Name und Einheit
|
|
41
|
+
zusammengefasst.
|
|
42
|
+
|
|
43
|
+
## Schnellstart
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uvx chefkoch-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Der Befehl startet den MCP-Server über stdio. In der Praxis wird er aus dem
|
|
50
|
+
MCP-Client heraus gestartet, nicht von Hand.
|
|
51
|
+
|
|
52
|
+
## In Claude Desktop / Cursor nutzen
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"mcpServers":{"chefkoch":{"command":"uvx","args":["chefkoch-mcp"]}}}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Claude Code
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
claude mcp add chefkoch -- uvx chefkoch-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
| Tool | Beschreibung |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `search_recipes` | Rezepte mit Filtern suchen |
|
|
69
|
+
| `get_recipe` | Rezept per ID, URL oder Name anzeigen |
|
|
70
|
+
| `shopping_list` | Einkaufsliste aus mehreren Rezepten erstellen |
|
|
71
|
+
|
|
72
|
+
Alle Tools sind lesend (`readOnlyHint`) und liefern strukturierte Daten plus
|
|
73
|
+
lesbaren Text. Fehler kommen als lesbare Meldung mit nächstem Schritt zurück.
|
|
74
|
+
|
|
75
|
+
## Datenquelle
|
|
76
|
+
|
|
77
|
+
Die Daten stammen von [chefkoch.de](https://www.chefkoch.de) über die
|
|
78
|
+
inoffizielle API. Das Projekt ist nicht mit Chefkoch verbunden, greift nur
|
|
79
|
+
lesend zu und benötigt keinen Login. Die API kann sich jederzeit ändern.
|
|
80
|
+
|
|
81
|
+
## Konfiguration
|
|
82
|
+
|
|
83
|
+
| Variable | Default | Bedeutung |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `CHEFKOCH_BASE_URL` | `https://api.chefkoch.de` | API-Basis (Test/Fake-Server) |
|
|
86
|
+
|
|
87
|
+
## Lizenz
|
|
88
|
+
|
|
89
|
+
MIT, Copyright 2026 smlfg. Siehe `LICENSE`.
|
|
90
|
+
|
|
91
|
+
<!-- mcp-name: io.github.smlfg/chefkoch-mcp -->
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# chefkoch-mcp
|
|
2
|
+
|
|
3
|
+
MCP-Server für Chefkoch.de: Rezepte suchen, Rezeptdetails anzeigen und
|
|
4
|
+
Einkaufslisten aus mehreren Rezepten erstellen. Ausschließlich lesend, ohne
|
|
5
|
+
Login.
|
|
6
|
+
|
|
7
|
+
Der Server ist eine dünne Hülle um den Kern der
|
|
8
|
+
[`chefkoch-cli`](https://github.com/smlfg/chefkoch-cli): Er importiert
|
|
9
|
+
`chefkoch_cli.service` und `chefkoch_cli.client` und ruft dieselben Funktionen
|
|
10
|
+
wie `chefkoch suche`, `chefkoch rezept` und `chefkoch einkaufsliste` auf.
|
|
11
|
+
|
|
12
|
+
## Was es kann
|
|
13
|
+
|
|
14
|
+
- **Rezepte suchen** mit Filtern für vegetarisch/vegan, maximale Zeit,
|
|
15
|
+
Mindestbewertung, Schwierigkeit, Sortierung und Seite.
|
|
16
|
+
- **Rezeptdetails** per ID, Chefkoch-URL oder Suchbegriff, Zutaten auf
|
|
17
|
+
Wunschportionen skaliert.
|
|
18
|
+
- **Einkaufsliste** aus mehreren Rezepten, Zutaten nach Name und Einheit
|
|
19
|
+
zusammengefasst.
|
|
20
|
+
|
|
21
|
+
## Schnellstart
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx chefkoch-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Der Befehl startet den MCP-Server über stdio. In der Praxis wird er aus dem
|
|
28
|
+
MCP-Client heraus gestartet, nicht von Hand.
|
|
29
|
+
|
|
30
|
+
## In Claude Desktop / Cursor nutzen
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{"mcpServers":{"chefkoch":{"command":"uvx","args":["chefkoch-mcp"]}}}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Claude Code
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
claude mcp add chefkoch -- uvx chefkoch-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
| Tool | Beschreibung |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `search_recipes` | Rezepte mit Filtern suchen |
|
|
47
|
+
| `get_recipe` | Rezept per ID, URL oder Name anzeigen |
|
|
48
|
+
| `shopping_list` | Einkaufsliste aus mehreren Rezepten erstellen |
|
|
49
|
+
|
|
50
|
+
Alle Tools sind lesend (`readOnlyHint`) und liefern strukturierte Daten plus
|
|
51
|
+
lesbaren Text. Fehler kommen als lesbare Meldung mit nächstem Schritt zurück.
|
|
52
|
+
|
|
53
|
+
## Datenquelle
|
|
54
|
+
|
|
55
|
+
Die Daten stammen von [chefkoch.de](https://www.chefkoch.de) über die
|
|
56
|
+
inoffizielle API. Das Projekt ist nicht mit Chefkoch verbunden, greift nur
|
|
57
|
+
lesend zu und benötigt keinen Login. Die API kann sich jederzeit ändern.
|
|
58
|
+
|
|
59
|
+
## Konfiguration
|
|
60
|
+
|
|
61
|
+
| Variable | Default | Bedeutung |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `CHEFKOCH_BASE_URL` | `https://api.chefkoch.de` | API-Basis (Test/Fake-Server) |
|
|
64
|
+
|
|
65
|
+
## Lizenz
|
|
66
|
+
|
|
67
|
+
MIT, Copyright 2026 smlfg. Siehe `LICENSE`.
|
|
68
|
+
|
|
69
|
+
<!-- mcp-name: io.github.smlfg/chefkoch-mcp -->
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chefkoch-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP-Server für Chefkoch.de: Rezepte suchen, anzeigen und Einkaufslisten erstellen"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "smlfg", email = "186922365+smlfg@users.noreply.github.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["chefkoch", "recipes", "mcp", "model-context-protocol"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=1.20,<2",
|
|
27
|
+
"chefkoch-cli>=0.1.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
chefkoch-mcp = "chefkoch_mcp.server:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/smlfg/chefkoch-mcp"
|
|
35
|
+
Repository = "https://github.com/smlfg/chefkoch-mcp"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = "-ra"
|
|
43
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""FastMCP-Server für Chefkoch.de – dünne Hülle um den Kern der chefkoch-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Annotated, Any, Callable, Literal, Sequence
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
from mcp.types import CallToolResult, TextContent, ToolAnnotations
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from chefkoch_cli import service
|
|
17
|
+
from chefkoch_cli.client import ApiClient
|
|
18
|
+
from chefkoch_cli.errors import CliError
|
|
19
|
+
from chefkoch_cli.formatting import (
|
|
20
|
+
format_recipe_text,
|
|
21
|
+
format_search_line,
|
|
22
|
+
format_shopping_text,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("chefkoch_mcp")
|
|
28
|
+
|
|
29
|
+
READ_ONLY = ToolAnnotations(
|
|
30
|
+
readOnlyHint=True,
|
|
31
|
+
destructiveHint=False,
|
|
32
|
+
idempotentHint=True,
|
|
33
|
+
openWorldHint=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
mcp = FastMCP(
|
|
37
|
+
"chefkoch",
|
|
38
|
+
instructions=(
|
|
39
|
+
"Read-only Zugriff auf Chefkoch.de: Rezepte suchen, Rezeptdetails mit "
|
|
40
|
+
"skalierten Zutaten anzeigen und Einkaufslisten aus mehreren Rezepten "
|
|
41
|
+
"erstellen. Alle Tools sind lesend und benötigen keinen Login."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _result(text: str, data: dict[str, Any]) -> CallToolResult:
|
|
47
|
+
return CallToolResult(
|
|
48
|
+
content=[TextContent(type="text", text=text)],
|
|
49
|
+
structuredContent=data,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _error(message: str) -> CallToolResult:
|
|
54
|
+
return CallToolResult(
|
|
55
|
+
content=[TextContent(type="text", text=message)],
|
|
56
|
+
isError=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_CLI_NEXT_STEP_REWRITES = (
|
|
61
|
+
(
|
|
62
|
+
re.compile(r"suche mit `chefkoch suche \.\.\.`"),
|
|
63
|
+
"nutze search_recipes",
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
re.compile(r"oder `chefkoch suche \.\.\.`"),
|
|
67
|
+
"oder search_recipes",
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
re.compile(r"Beispiel: chefkoch suche lasagne"),
|
|
71
|
+
"Nutze search_recipes mit einem Suchbegriff, z. B. 'lasagne'.",
|
|
72
|
+
),
|
|
73
|
+
(
|
|
74
|
+
re.compile(r'Beispiel: chefkoch rezept "vegane lasagne"'),
|
|
75
|
+
"Nutze get_recipe mit Name, ID oder URL.",
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
re.compile(
|
|
79
|
+
r"Nutze eine ID/URL oder die interaktive Auswahl im Terminal\."
|
|
80
|
+
),
|
|
81
|
+
"Nutze eine eindeutige ID/URL mit get_recipe.",
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
re.compile(r"`chefkoch suche [^`]*`"),
|
|
85
|
+
"search_recipes",
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
re.compile(r"`chefkoch rezept [^`]*`"),
|
|
89
|
+
"get_recipe",
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
re.compile(r"`chefkoch einkaufsliste [^`]*`"),
|
|
93
|
+
"shopping_list",
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _next_step_for_mcp(next_step: str) -> str:
|
|
99
|
+
"""Ersetzt CLI-Befehle im nächsten Schritt durch die passenden MCP-Tools."""
|
|
100
|
+
text = next_step
|
|
101
|
+
for pattern, replacement in _CLI_NEXT_STEP_REWRITES:
|
|
102
|
+
text = pattern.sub(replacement, text)
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _guard(action: Callable[[], CallToolResult]) -> CallToolResult:
|
|
107
|
+
try:
|
|
108
|
+
return action()
|
|
109
|
+
except CliError as exc:
|
|
110
|
+
message = exc.message
|
|
111
|
+
if exc.next_step:
|
|
112
|
+
next_step = _next_step_for_mcp(exc.next_step)
|
|
113
|
+
message = f"{message} Nächster Schritt: {next_step}"
|
|
114
|
+
return _error(message)
|
|
115
|
+
except (httpx.TimeoutException, TimeoutError):
|
|
116
|
+
return _error(
|
|
117
|
+
"Zeitüberschreitung beim Abruf von Chefkoch.de. "
|
|
118
|
+
"Nächster Schritt: Verbindung prüfen und erneut versuchen."
|
|
119
|
+
)
|
|
120
|
+
except httpx.HTTPError as exc:
|
|
121
|
+
return _error(
|
|
122
|
+
f"Netzwerkfehler beim Abruf von Chefkoch.de: {exc}. "
|
|
123
|
+
"Nächster Schritt: Verbindung prüfen und erneut versuchen."
|
|
124
|
+
)
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
return _error(
|
|
127
|
+
f"Verbindungsfehler zu Chefkoch.de: {exc}. "
|
|
128
|
+
"Nächster Schritt: CHEFKOCH_BASE_URL und Netzwerk prüfen."
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - nie einen Traceback an den Client geben
|
|
131
|
+
logger.exception("Unerwarteter Fehler in einem MCP-Tool")
|
|
132
|
+
return _error(
|
|
133
|
+
f"Unerwarteter Fehler: {exc}. "
|
|
134
|
+
"Nächster Schritt: Eingaben prüfen oder es später erneut versuchen."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.tool(
|
|
139
|
+
name="search_recipes",
|
|
140
|
+
description=(
|
|
141
|
+
"Sucht Rezepte auf Chefkoch.de mit Filtern und liefert eine sortierte "
|
|
142
|
+
"Trefferliste. Search Chefkoch recipes with filters; read-only."
|
|
143
|
+
),
|
|
144
|
+
annotations=ToolAnnotations(
|
|
145
|
+
title="Rezepte suchen",
|
|
146
|
+
readOnlyHint=READ_ONLY.readOnlyHint,
|
|
147
|
+
destructiveHint=READ_ONLY.destructiveHint,
|
|
148
|
+
idempotentHint=READ_ONLY.idempotentHint,
|
|
149
|
+
openWorldHint=READ_ONLY.openWorldHint,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
def search_recipes(
|
|
153
|
+
query: Annotated[
|
|
154
|
+
str,
|
|
155
|
+
Field(description="Suchbegriff, z. B. 'lasagne' oder 'vegane suppe'."),
|
|
156
|
+
],
|
|
157
|
+
limit: Annotated[
|
|
158
|
+
int,
|
|
159
|
+
Field(description="Treffer pro Seite (1 bis 100).", ge=1, le=100),
|
|
160
|
+
] = 10,
|
|
161
|
+
vegetarisch: Annotated[
|
|
162
|
+
bool,
|
|
163
|
+
Field(description="Nur vegetarische Rezepte (Detailprüfung über Tags)."),
|
|
164
|
+
] = False,
|
|
165
|
+
vegan: Annotated[
|
|
166
|
+
bool,
|
|
167
|
+
Field(description="Nur vegane Rezepte; schließt vegetarisch ein."),
|
|
168
|
+
] = False,
|
|
169
|
+
max_zeit: Annotated[
|
|
170
|
+
int | None,
|
|
171
|
+
Field(
|
|
172
|
+
description="Maximale Zubereitungszeit in Minuten (>= 0).",
|
|
173
|
+
ge=0,
|
|
174
|
+
),
|
|
175
|
+
] = None,
|
|
176
|
+
min_bewertung: Annotated[
|
|
177
|
+
float | None,
|
|
178
|
+
Field(
|
|
179
|
+
description="Minimale Bewertung von 0 bis 5 Sternen.",
|
|
180
|
+
ge=0,
|
|
181
|
+
le=5,
|
|
182
|
+
),
|
|
183
|
+
] = None,
|
|
184
|
+
schwierigkeit: Annotated[
|
|
185
|
+
Literal["simpel", "normal", "pfiffig"] | None,
|
|
186
|
+
Field(description="Schwierigkeitsgrad: simpel, normal oder pfiffig."),
|
|
187
|
+
] = None,
|
|
188
|
+
sort: Annotated[
|
|
189
|
+
Literal["bewertung", "zeit", "neu"],
|
|
190
|
+
Field(
|
|
191
|
+
description=(
|
|
192
|
+
"Sortierung: bewertung (Standard), zeit oder neu."
|
|
193
|
+
)
|
|
194
|
+
),
|
|
195
|
+
] = "bewertung",
|
|
196
|
+
seite: Annotated[
|
|
197
|
+
int,
|
|
198
|
+
Field(description="Seite der clientseitig sortierten Treffer (>= 1).", ge=1),
|
|
199
|
+
] = 1,
|
|
200
|
+
) -> CallToolResult:
|
|
201
|
+
"""Sucht Rezepte über dieselbe Service-Funktion wie ``chefkoch suche``."""
|
|
202
|
+
|
|
203
|
+
def action() -> CallToolResult:
|
|
204
|
+
with ApiClient() as client:
|
|
205
|
+
data = service.search_recipes(
|
|
206
|
+
client,
|
|
207
|
+
query,
|
|
208
|
+
vegetarisch=vegetarisch,
|
|
209
|
+
vegan=vegan,
|
|
210
|
+
max_zeit=max_zeit,
|
|
211
|
+
min_bewertung=min_bewertung,
|
|
212
|
+
schwierigkeit=schwierigkeit,
|
|
213
|
+
sort=sort,
|
|
214
|
+
limit=limit,
|
|
215
|
+
seite=seite,
|
|
216
|
+
)
|
|
217
|
+
text = "\n".join(
|
|
218
|
+
format_search_line(index, item)
|
|
219
|
+
for index, item in enumerate(data.get("results") or [], start=1)
|
|
220
|
+
)
|
|
221
|
+
return _result(text, data)
|
|
222
|
+
|
|
223
|
+
return _guard(action)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool(
|
|
227
|
+
name="get_recipe",
|
|
228
|
+
description=(
|
|
229
|
+
"Zeigt ein Chefkoch-Rezept per ID, URL oder Suchbegriff mit auf "
|
|
230
|
+
"Portionen skalierten Zutaten. Show a Chefkoch recipe; read-only."
|
|
231
|
+
),
|
|
232
|
+
annotations=ToolAnnotations(
|
|
233
|
+
title="Rezept anzeigen",
|
|
234
|
+
readOnlyHint=READ_ONLY.readOnlyHint,
|
|
235
|
+
destructiveHint=READ_ONLY.destructiveHint,
|
|
236
|
+
idempotentHint=READ_ONLY.idempotentHint,
|
|
237
|
+
openWorldHint=READ_ONLY.openWorldHint,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
def get_recipe(
|
|
241
|
+
recipe: Annotated[
|
|
242
|
+
str | int,
|
|
243
|
+
Field(
|
|
244
|
+
description=(
|
|
245
|
+
"Rezept-ID (auch als Zahl), Chefkoch-URL oder Suchbegriff, z. B. "
|
|
246
|
+
"'2812481433250378', 2812481433250378, "
|
|
247
|
+
"'https://www.chefkoch.de/rezepte/...' oder 'vegane lasagne'."
|
|
248
|
+
)
|
|
249
|
+
),
|
|
250
|
+
],
|
|
251
|
+
portions: Annotated[
|
|
252
|
+
int | None,
|
|
253
|
+
Field(
|
|
254
|
+
description=(
|
|
255
|
+
"Zielportionen für die Zutaten (>= 1); ohne Angabe gelten die "
|
|
256
|
+
"Portionen des Rezepts."
|
|
257
|
+
),
|
|
258
|
+
ge=1,
|
|
259
|
+
),
|
|
260
|
+
] = None,
|
|
261
|
+
) -> CallToolResult:
|
|
262
|
+
"""Lädt ein Rezept über dieselbe Service-Funktion wie ``chefkoch rezept``."""
|
|
263
|
+
|
|
264
|
+
def action() -> CallToolResult:
|
|
265
|
+
servings = service.validate_servings(portions)
|
|
266
|
+
with ApiClient() as client:
|
|
267
|
+
data = service.load_recipe(client, str(recipe), servings)
|
|
268
|
+
return _result(format_recipe_text(data), data)
|
|
269
|
+
|
|
270
|
+
return _guard(action)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@mcp.tool(
|
|
274
|
+
name="shopping_list",
|
|
275
|
+
description=(
|
|
276
|
+
"Erstellt aus mehreren Chefkoch-Rezepten eine zusammengefasste "
|
|
277
|
+
"Einkaufsliste mit skalierten Mengen. Build a shopping list; read-only."
|
|
278
|
+
),
|
|
279
|
+
annotations=ToolAnnotations(
|
|
280
|
+
title="Einkaufsliste erstellen",
|
|
281
|
+
readOnlyHint=READ_ONLY.readOnlyHint,
|
|
282
|
+
destructiveHint=READ_ONLY.destructiveHint,
|
|
283
|
+
idempotentHint=READ_ONLY.idempotentHint,
|
|
284
|
+
openWorldHint=READ_ONLY.openWorldHint,
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
def shopping_list(
|
|
288
|
+
recipes: Annotated[
|
|
289
|
+
list[str | int],
|
|
290
|
+
Field(
|
|
291
|
+
description=(
|
|
292
|
+
"Rezept-IDs (auch als Zahl), Chefkoch-URLs oder Suchbegriffe; "
|
|
293
|
+
"mindestens ein Eintrag."
|
|
294
|
+
),
|
|
295
|
+
min_length=1,
|
|
296
|
+
),
|
|
297
|
+
],
|
|
298
|
+
portions: Annotated[
|
|
299
|
+
int | None,
|
|
300
|
+
Field(
|
|
301
|
+
description=(
|
|
302
|
+
"Zielportionen für jedes Rezept (>= 1); ohne Angabe gelten die "
|
|
303
|
+
"Portionen der Rezepte."
|
|
304
|
+
),
|
|
305
|
+
ge=1,
|
|
306
|
+
),
|
|
307
|
+
] = None,
|
|
308
|
+
) -> CallToolResult:
|
|
309
|
+
"""Baut die Einkaufsliste über dieselben Funktionen wie ``chefkoch einkaufsliste``."""
|
|
310
|
+
|
|
311
|
+
def action() -> CallToolResult:
|
|
312
|
+
servings = service.validate_servings(portions)
|
|
313
|
+
loaded: list[dict[str, Any]] = []
|
|
314
|
+
with ApiClient() as client:
|
|
315
|
+
for reference in recipes:
|
|
316
|
+
loaded.append(
|
|
317
|
+
service.load_recipe(client, str(reference), servings)
|
|
318
|
+
)
|
|
319
|
+
data = service.build_shopping_list(loaded, servings)
|
|
320
|
+
return _result(format_shopping_text(data["items"], servings), data)
|
|
321
|
+
|
|
322
|
+
return _guard(action)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
326
|
+
parser = argparse.ArgumentParser(
|
|
327
|
+
prog="chefkoch-mcp",
|
|
328
|
+
description=(
|
|
329
|
+
"MCP-Server für Chefkoch.de (stdio, ausschließlich lesend). "
|
|
330
|
+
"Startet ohne Argumente den Server."
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"--version",
|
|
335
|
+
action="version",
|
|
336
|
+
version=f"chefkoch-mcp {__version__}",
|
|
337
|
+
)
|
|
338
|
+
parser.parse_args(argv)
|
|
339
|
+
|
|
340
|
+
logging.basicConfig(
|
|
341
|
+
level=logging.WARNING,
|
|
342
|
+
stream=sys.stderr,
|
|
343
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
344
|
+
)
|
|
345
|
+
mcp.run()
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chefkoch-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP-Server für Chefkoch.de: Rezepte suchen, anzeigen und Einkaufslisten erstellen
|
|
5
|
+
Author-email: smlfg <186922365+smlfg@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/smlfg/chefkoch-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/smlfg/chefkoch-mcp
|
|
9
|
+
Keywords: chefkoch,recipes,mcp,model-context-protocol
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: mcp<2,>=1.20
|
|
20
|
+
Requires-Dist: chefkoch-cli>=0.1.1
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# chefkoch-mcp
|
|
24
|
+
|
|
25
|
+
MCP-Server für Chefkoch.de: Rezepte suchen, Rezeptdetails anzeigen und
|
|
26
|
+
Einkaufslisten aus mehreren Rezepten erstellen. Ausschließlich lesend, ohne
|
|
27
|
+
Login.
|
|
28
|
+
|
|
29
|
+
Der Server ist eine dünne Hülle um den Kern der
|
|
30
|
+
[`chefkoch-cli`](https://github.com/smlfg/chefkoch-cli): Er importiert
|
|
31
|
+
`chefkoch_cli.service` und `chefkoch_cli.client` und ruft dieselben Funktionen
|
|
32
|
+
wie `chefkoch suche`, `chefkoch rezept` und `chefkoch einkaufsliste` auf.
|
|
33
|
+
|
|
34
|
+
## Was es kann
|
|
35
|
+
|
|
36
|
+
- **Rezepte suchen** mit Filtern für vegetarisch/vegan, maximale Zeit,
|
|
37
|
+
Mindestbewertung, Schwierigkeit, Sortierung und Seite.
|
|
38
|
+
- **Rezeptdetails** per ID, Chefkoch-URL oder Suchbegriff, Zutaten auf
|
|
39
|
+
Wunschportionen skaliert.
|
|
40
|
+
- **Einkaufsliste** aus mehreren Rezepten, Zutaten nach Name und Einheit
|
|
41
|
+
zusammengefasst.
|
|
42
|
+
|
|
43
|
+
## Schnellstart
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uvx chefkoch-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Der Befehl startet den MCP-Server über stdio. In der Praxis wird er aus dem
|
|
50
|
+
MCP-Client heraus gestartet, nicht von Hand.
|
|
51
|
+
|
|
52
|
+
## In Claude Desktop / Cursor nutzen
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"mcpServers":{"chefkoch":{"command":"uvx","args":["chefkoch-mcp"]}}}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Claude Code
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
claude mcp add chefkoch -- uvx chefkoch-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
| Tool | Beschreibung |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `search_recipes` | Rezepte mit Filtern suchen |
|
|
69
|
+
| `get_recipe` | Rezept per ID, URL oder Name anzeigen |
|
|
70
|
+
| `shopping_list` | Einkaufsliste aus mehreren Rezepten erstellen |
|
|
71
|
+
|
|
72
|
+
Alle Tools sind lesend (`readOnlyHint`) und liefern strukturierte Daten plus
|
|
73
|
+
lesbaren Text. Fehler kommen als lesbare Meldung mit nächstem Schritt zurück.
|
|
74
|
+
|
|
75
|
+
## Datenquelle
|
|
76
|
+
|
|
77
|
+
Die Daten stammen von [chefkoch.de](https://www.chefkoch.de) über die
|
|
78
|
+
inoffizielle API. Das Projekt ist nicht mit Chefkoch verbunden, greift nur
|
|
79
|
+
lesend zu und benötigt keinen Login. Die API kann sich jederzeit ändern.
|
|
80
|
+
|
|
81
|
+
## Konfiguration
|
|
82
|
+
|
|
83
|
+
| Variable | Default | Bedeutung |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `CHEFKOCH_BASE_URL` | `https://api.chefkoch.de` | API-Basis (Test/Fake-Server) |
|
|
86
|
+
|
|
87
|
+
## Lizenz
|
|
88
|
+
|
|
89
|
+
MIT, Copyright 2026 smlfg. Siehe `LICENSE`.
|
|
90
|
+
|
|
91
|
+
<!-- mcp-name: io.github.smlfg/chefkoch-mcp -->
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/chefkoch_mcp/__init__.py
|
|
5
|
+
src/chefkoch_mcp/__main__.py
|
|
6
|
+
src/chefkoch_mcp/server.py
|
|
7
|
+
src/chefkoch_mcp.egg-info/PKG-INFO
|
|
8
|
+
src/chefkoch_mcp.egg-info/SOURCES.txt
|
|
9
|
+
src/chefkoch_mcp.egg-info/dependency_links.txt
|
|
10
|
+
src/chefkoch_mcp.egg-info/entry_points.txt
|
|
11
|
+
src/chefkoch_mcp.egg-info/requires.txt
|
|
12
|
+
src/chefkoch_mcp.egg-info/top_level.txt
|
|
13
|
+
tests/test_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chefkoch_mcp
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import parse_qs, urlsplit
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from mcp import ClientSession, StdioServerParameters
|
|
17
|
+
from mcp.client.stdio import stdio_client
|
|
18
|
+
from mcp.types import CallToolResult
|
|
19
|
+
|
|
20
|
+
FIXTURES = Path(__file__).resolve().parent / "fixtures" / "mcp"
|
|
21
|
+
|
|
22
|
+
SEARCH_ROUTES = (
|
|
23
|
+
("ultimative", "search_ultimative_vegane_lasagne.json"),
|
|
24
|
+
("spinat", "search_vegetarische_spinat_lasagne.json"),
|
|
25
|
+
("vegane", "search_vegane_lasagne.json"),
|
|
26
|
+
("lasagne", "search_lasagne.json"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeChefkochHandler(BaseHTTPRequestHandler):
|
|
31
|
+
def do_GET(self) -> None: # noqa: N802 - von BaseHTTPRequestHandler vorgegeben
|
|
32
|
+
parts = urlsplit(self.path)
|
|
33
|
+
if parts.path == "/v2/recipes":
|
|
34
|
+
query = " ".join(parse_qs(parts.query).get("query", []))
|
|
35
|
+
for needle, filename in SEARCH_ROUTES:
|
|
36
|
+
if needle in query:
|
|
37
|
+
self._send_json(filename)
|
|
38
|
+
return
|
|
39
|
+
self._send_status(404)
|
|
40
|
+
return
|
|
41
|
+
if parts.path.startswith("/v2/recipes/"):
|
|
42
|
+
recipe_id = parts.path.rsplit("/", 1)[-1]
|
|
43
|
+
filename = f"recipe_{recipe_id}.json"
|
|
44
|
+
if (FIXTURES / filename).is_file():
|
|
45
|
+
self._send_json(filename)
|
|
46
|
+
return
|
|
47
|
+
self._send_status(404)
|
|
48
|
+
|
|
49
|
+
def _send_json(self, filename: str) -> None:
|
|
50
|
+
body = (FIXTURES / filename).read_bytes()
|
|
51
|
+
self.send_response(200)
|
|
52
|
+
self.send_header("Content-Type", "application/json")
|
|
53
|
+
self.send_header("Content-Length", str(len(body)))
|
|
54
|
+
self.end_headers()
|
|
55
|
+
self.wfile.write(body)
|
|
56
|
+
|
|
57
|
+
def _send_status(self, status: int) -> None:
|
|
58
|
+
body = json.dumps({"error": status}).encode("utf-8")
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
self.send_header("Content-Length", str(len(body)))
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(body)
|
|
64
|
+
|
|
65
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture(scope="module")
|
|
70
|
+
def fake_api() -> str:
|
|
71
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeChefkochHandler)
|
|
72
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
73
|
+
thread.start()
|
|
74
|
+
try:
|
|
75
|
+
yield f"http://127.0.0.1:{server.server_address[1]}"
|
|
76
|
+
finally:
|
|
77
|
+
server.shutdown()
|
|
78
|
+
server.server_close()
|
|
79
|
+
thread.join(timeout=5)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _server_params(base_url: str) -> StdioServerParameters:
|
|
83
|
+
env = dict(os.environ)
|
|
84
|
+
env["CHEFKOCH_BASE_URL"] = base_url
|
|
85
|
+
env["CHEFKOCH_MIN_INTERVAL"] = "0"
|
|
86
|
+
return StdioServerParameters(
|
|
87
|
+
command=sys.executable,
|
|
88
|
+
args=["-m", "chefkoch_mcp"],
|
|
89
|
+
env=env,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _text(result: CallToolResult) -> str:
|
|
94
|
+
return "\n".join(
|
|
95
|
+
block.text for block in result.content if getattr(block, "type", None) == "text"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _list_tools(base_url: str):
|
|
100
|
+
async def run():
|
|
101
|
+
async with stdio_client(_server_params(base_url)) as (read, write):
|
|
102
|
+
async with ClientSession(read, write) as session:
|
|
103
|
+
await session.initialize()
|
|
104
|
+
return await session.list_tools()
|
|
105
|
+
|
|
106
|
+
return asyncio.run(run())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _call(base_url: str, name: str, arguments: dict[str, Any]) -> CallToolResult:
|
|
110
|
+
async def run():
|
|
111
|
+
async with stdio_client(_server_params(base_url)) as (read, write):
|
|
112
|
+
async with ClientSession(read, write) as session:
|
|
113
|
+
await session.initialize()
|
|
114
|
+
return await session.call_tool(name, arguments)
|
|
115
|
+
|
|
116
|
+
return asyncio.run(run())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _free_port() -> int:
|
|
120
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
121
|
+
sock.bind(("127.0.0.1", 0))
|
|
122
|
+
return int(sock.getsockname()[1])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_tools_list_has_three_read_only_tools(fake_api: str) -> None:
|
|
126
|
+
result = _list_tools(fake_api)
|
|
127
|
+
tools = {tool.name: tool for tool in result.tools}
|
|
128
|
+
|
|
129
|
+
assert set(tools) == {"search_recipes", "get_recipe", "shopping_list"}
|
|
130
|
+
for tool in tools.values():
|
|
131
|
+
assert tool.description
|
|
132
|
+
assert len(tool.description) >= 40
|
|
133
|
+
assert tool.annotations is not None
|
|
134
|
+
assert tool.annotations.title
|
|
135
|
+
assert tool.annotations.readOnlyHint is True
|
|
136
|
+
assert tool.annotations.destructiveHint is False
|
|
137
|
+
assert tool.annotations.idempotentHint is True
|
|
138
|
+
assert tool.annotations.openWorldHint is True
|
|
139
|
+
properties = tool.inputSchema.get("properties") or {}
|
|
140
|
+
assert properties
|
|
141
|
+
for name, prop in properties.items():
|
|
142
|
+
assert prop.get("description"), f"{tool.name}.{name} ohne Beschreibung"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_search_recipes_returns_structured_and_readable_result(fake_api: str) -> None:
|
|
146
|
+
result = _call(fake_api, "search_recipes", {"query": "lasagne", "limit": 5})
|
|
147
|
+
|
|
148
|
+
assert result.isError is False
|
|
149
|
+
text = _text(result)
|
|
150
|
+
assert "Lasagne" in text
|
|
151
|
+
assert "745721177147257" in text
|
|
152
|
+
assert result.structuredContent is not None
|
|
153
|
+
assert result.structuredContent["query"] == "lasagne"
|
|
154
|
+
assert result.structuredContent["results"][0]["id"] == "745721177147257"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_get_recipe_scales_ingredients(fake_api: str) -> None:
|
|
158
|
+
result = _call(
|
|
159
|
+
fake_api,
|
|
160
|
+
"get_recipe",
|
|
161
|
+
{"recipe": "2812481433250378", "portions": 4},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert result.isError is False
|
|
165
|
+
text = _text(result)
|
|
166
|
+
assert "Die ultimative vegane Lasagne" in text
|
|
167
|
+
assert "Portionen: 4 (Basis: 8)" in text
|
|
168
|
+
assert result.structuredContent is not None
|
|
169
|
+
assert result.structuredContent["requestedServings"] == 4
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_get_recipe_accepts_numeric_id(fake_api: str) -> None:
|
|
173
|
+
result = _call(
|
|
174
|
+
fake_api,
|
|
175
|
+
"get_recipe",
|
|
176
|
+
{"recipe": 2812481433250378, "portions": 4},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert result.isError is False
|
|
180
|
+
text = _text(result)
|
|
181
|
+
assert "Die ultimative vegane Lasagne" in text
|
|
182
|
+
assert result.structuredContent is not None
|
|
183
|
+
assert result.structuredContent["id"] == "2812481433250378"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_shopping_list_accepts_numeric_ids(fake_api: str) -> None:
|
|
187
|
+
result = _call(
|
|
188
|
+
fake_api,
|
|
189
|
+
"shopping_list",
|
|
190
|
+
{
|
|
191
|
+
"recipes": [2812481433250378, 2114131340630587],
|
|
192
|
+
"portions": 4,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert result.isError is False
|
|
197
|
+
text = _text(result)
|
|
198
|
+
assert "Knoblauchzehe(n)" in text
|
|
199
|
+
assert result.structuredContent is not None
|
|
200
|
+
assert [
|
|
201
|
+
recipe["id"] for recipe in result.structuredContent["recipes"]
|
|
202
|
+
] == ["2812481433250378", "2114131340630587"]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_shopping_list_aggregates_ingredients(fake_api: str) -> None:
|
|
206
|
+
result = _call(
|
|
207
|
+
fake_api,
|
|
208
|
+
"shopping_list",
|
|
209
|
+
{
|
|
210
|
+
"recipes": ["2812481433250378", "2114131340630587"],
|
|
211
|
+
"portions": 4,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
assert result.isError is False
|
|
216
|
+
text = _text(result)
|
|
217
|
+
assert "Einkaufsliste (4 Portionen je Rezept)" in text
|
|
218
|
+
assert "Knoblauchzehe(n)" in text
|
|
219
|
+
assert "Käse, geriebener" in text
|
|
220
|
+
assert result.structuredContent is not None
|
|
221
|
+
items = {
|
|
222
|
+
(item["name"], item["unit"]): item
|
|
223
|
+
for item in result.structuredContent["items"]
|
|
224
|
+
}
|
|
225
|
+
assert items[("Knoblauchzehe(n)", "")]["amount"] == pytest.approx(4.67)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_error_next_steps_name_mcp_tools_instead_of_cli_commands(
|
|
229
|
+
fake_api: str,
|
|
230
|
+
) -> None:
|
|
231
|
+
missing = _call(fake_api, "get_recipe", {"recipe": "0000000000"})
|
|
232
|
+
missing_text = _text(missing)
|
|
233
|
+
assert missing.isError is True
|
|
234
|
+
assert "Traceback" not in missing_text
|
|
235
|
+
assert "Nächster Schritt: Prüfe die ID/URL oder nutze search_recipes." in (
|
|
236
|
+
missing_text
|
|
237
|
+
)
|
|
238
|
+
assert "chefkoch suche" not in missing_text.lower()
|
|
239
|
+
|
|
240
|
+
empty_query = _call(fake_api, "search_recipes", {"query": " "})
|
|
241
|
+
empty_text = _text(empty_query)
|
|
242
|
+
assert empty_query.isError is True
|
|
243
|
+
assert "Traceback" not in empty_text
|
|
244
|
+
assert "search_recipes" in empty_text
|
|
245
|
+
assert "chefkoch suche" not in empty_text.lower()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_unreachable_server_is_error_without_traceback() -> None:
|
|
249
|
+
base_url = f"http://127.0.0.1:{_free_port()}"
|
|
250
|
+
result = _call(base_url, "search_recipes", {"query": "lasagne"})
|
|
251
|
+
|
|
252
|
+
assert result.isError is True
|
|
253
|
+
text = _text(result)
|
|
254
|
+
assert "Traceback" not in text
|
|
255
|
+
assert "Netzwerkfehler" in text
|
|
256
|
+
assert "Prüfe die Verbindung" in text
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_version_and_help_exit_without_starting_server() -> None:
|
|
260
|
+
for flag in ("--version", "--help"):
|
|
261
|
+
completed = subprocess.run(
|
|
262
|
+
[sys.executable, "-m", "chefkoch_mcp", flag],
|
|
263
|
+
capture_output=True,
|
|
264
|
+
text=True,
|
|
265
|
+
timeout=20,
|
|
266
|
+
env=dict(os.environ),
|
|
267
|
+
)
|
|
268
|
+
assert completed.returncode == 0, completed.stderr
|
|
269
|
+
assert completed.stdout
|