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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """MCP-Server für Chefkoch.de auf dem Kern der chefkoch-cli."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Einstieg für ``python -m chefkoch_mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .server import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ chefkoch-mcp = chefkoch_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp<2,>=1.20
2
+ chefkoch-cli>=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