From ebbbf38ab930cb129101f28c0e8a9d1643606330 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 16 Apr 2026 14:51:53 +0200 Subject: [PATCH] feat(recipes): implement get_recipes, get_recipe, create_recipe, delete_recipe (v0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 new MCP tools for the Family Wall recipe box: - get_recipes: list all family recipes via metasync id='recipe' - get_recipe: fetch full recipe detail by id (filters from metasync response) - create_recipe: create a new recipe via mprecipeput (params use 'recipe.' prefix) - delete_recipe: delete a recipe via metadelete (same endpoint as tasks) Verified endpoints and parameter names via FW_DEBUG=1 probe scripts. All 4 tools pass the create → read → get_single → delete integration test. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 14 +- README.md | 8 +- SPEC.md | 86 ++++++++++ pyproject.toml | 2 +- src/mcp_familywall/__init__.py | 2 +- src/mcp_familywall/modules/recipes.py | 120 +++++++++++++ src/mcp_familywall/server.py | 238 +++++++++++++++++++++++++- 7 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 src/mcp_familywall/modules/recipes.py diff --git a/CLAUDE.md b/CLAUDE.md index df54488..cba2771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden. ## Aktueller Stand -### Implementierte Tools (v0.5.x) +### Implementierte Tools (v0.6.0) | Kategorie | Tools | |---|---| @@ -33,15 +33,19 @@ und wird in Claude Desktop eingebunden. | Listen | `create_list`, `delete_list` | | Kategorien | `create_category`, `delete_category` | | Aktivitäten | `like_post` | +| Rezepte | `get_recipes`, `get_recipe`, `create_recipe`, `delete_recipe` | ## Roadmap - v0.4.x: Kategorie-Management, Task-Felder (due_date, assignee, list_id) ✓ - v0.5.x: Listen-Management (create_list, delete_list) ✓ - v0.5.1: emoji + color in get_lists / create_list ✓ -- v0.5.2: Mengenkonvention im create_task Docstring ← aktuell +- v0.5.2: Mengenkonvention im create_task Docstring ✓ +- v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓ ← aktuell +- v0.6.1: update_recipe (Rezept bearbeiten) +- v0.6.2: mpadditemtolist (Zutaten → Einkaufsliste) - v0.5.3: update_list (Umbenennen, emoji/color ändern), Sharing-Verwaltung -- v0.6.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) +- v0.7.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) - v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren) @@ -122,12 +126,14 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level: | `taskupdate2` | `metaId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee`, `taskListId` | – | | `taskupdate2` | `dueDate` löschen | `$empty` | | `taskmark` | `taskId`, `complete` | `"true"`/`"false"` | -| `metadelete` | `id` | metaId des Tasks | +| `metadelete` | `id` | metaId des Tasks / Rezepts | | `wallmood` | `wall_message_id`, `moodType` | `"STAR"` für Like | | `taskcategoryput` | `name`, `emoji` | – | | `taskcategorydelete` | `id` | metaId der Kategorie | | `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji` | `taskListType`: `"SHOPPING_LIST"`/`"TODOS"` | | `taskdeletelist` | `id` | metaId der Liste | +| `mprecipeput` | `recipe.name`, `recipe.isRecipe="true"`, `recipe.description`, `recipe.ingredients`, `recipe.instructions`, `recipe.prepTime`, `recipe.cookTime`, `recipe.serves`, `recipe.url` | Alle mit `recipe.`-Prefix! | +| `metasync` (Rezepte lesen) | `id="recipe"` | liefert `a00.r.r.updatedCreated[]` | ### Self-Like-Restriction Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts. diff --git a/README.md b/README.md index c32f0dd..c3d3516 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # mcp-familywall -MCP server for [Family Wall](https://www.familywall.com) -- read and manage your family's circles, lists, and tasks directly from Claude. +MCP server for [Family Wall](https://www.familywall.com) -- read and manage your family's circles, lists, tasks, and recipes directly from Claude. -## Features (v0.5.2) +## Features (v0.6.0) ### Read @@ -12,6 +12,8 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your - `get_tasks` -- list tasks in a specific list (includes `category_id`, `due_date`, `assignee_ids`) - `get_categories` -- list categories for a list (locale-filtered; custom categories always included; `custom` flag marks user-created ones) - `get_activities` -- list recent wall activities (author resolved to display name) +- `get_recipes` -- list all family recipes (compact summary: id, name, prep/cook time, serves) +- `get_recipe` -- get a single recipe in full detail (ingredients, instructions, ingredients_parsed, etc.) ### Write (with confirmation prompt) @@ -24,6 +26,8 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your - `create_category` -- create a custom category for a shopping list (with optional icon) - `delete_category` -- delete a custom category (system categories are protected) - `like_post` -- like a wall post/activity +- `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url) +- `delete_recipe` -- permanently delete a recipe (only own recipes) ## Requirements diff --git a/SPEC.md b/SPEC.md index 0ba74d7..3732c10 100644 --- a/SPEC.md +++ b/SPEC.md @@ -387,9 +387,95 @@ Operationen. zeigen nicht den echten Request-Body für diese Calls. **Lösung:** `FW_DEBUG=1` auf MCP-Server-Seite zeigt was tatsächlich gesendet wird. +### `mprecipeput` – Rezept erstellen +POST https://api.familywall.com/api/mprecipeput + +**Wichtig:** Alle Parameter haben das Präfix `recipe.` (verifiziert via FW_DEBUG=1). + +**Body-Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `recipe.name` | ja | Rezeptname | +| `recipe.isRecipe` | ja | immer `"true"` | +| `recipe.description` | nein | Kurzbeschreibung | +| `recipe.ingredients` | nein | Zutaten als Freitext (Zeilen mit `\n` trennen) | +| `recipe.instructions` | nein | Anleitung als Freitext (Zeilen mit `\n` trennen) | +| `recipe.prepTime` | nein | Zubereitungszeit in Minuten (String, z.B. `"30"`) | +| `recipe.cookTime` | nein | Kochzeit in Minuten (String, z.B. `"60"`) | +| `recipe.serves` | nein | Portionen (String, z.B. `"4"`) | +| `recipe.url` | nein | externe URL | + +**Response:** +``` +a00.r.r → vollständiges Rezept-Objekt + .metaId → neue Rezept-ID (z.B. "recipe/23431854_10968866") + .name → Rezeptname + .description → Beschreibung + .ingredients → Zutaten Freitext (API liefert \r\n als Zeilenumbrüche) + .ingredientsList[] → auto-geparste Zutaten (read-only, vom Server generiert) + .metaId → "recipeIngredient/" + .name → Zutatname + .instructions → Anleitung Freitext + .prepTime → Zubereitungszeit als String ("30") + .cookTime → Kochzeit als String ("60") + .serves → Portionen als String ("4") + .url → externe URL (fehlt wenn leer) + .isRecipe → "true" + .isFavorite → "false" + .recipeCategories[] → [] + .recipeCategoryIdList[] → [] + .rights.canDelete → "true" für eigene Rezepte + .rights.canUpdate → "true" für eigene Rezepte + .familyId, .accountId, .creationDate, .moodMap, .moodStarShortcut +``` + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + +### `metasync` (id='recipe') – Alle Rezepte abrufen +POST https://api.familywall.com/api/metasync + +**Body-Parameter:** + +| Parameter | Wert | +|---|---| +| `id` | `"recipe"` (lowercase, enum-Wert) | + +**Response-Struktur:** +``` +a00.r.r.updatedCreated[] → Liste aller Rezepte der Familie + → Felder identisch mit mprecipeput-Response (siehe oben) +``` + +**Hinweis:** Der Parameter `id` nimmt einen MetaIdTypeEnum-Wert, kein tatsächliches Objekt. +Nur `"recipe"` (lowercase) funktioniert – `"RECIPE"`, `"Recipe"` und andere Schreibweisen +liefern `MetaIdTypeEnum`-Fehler. + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + +### `metadelete` – Rezept löschen +POST https://api.familywall.com/api/metadelete + +Identisch mit dem Task-Löschen-Endpoint. Funktioniert auch für Rezepte. + +**Body-Parameter:** + +| Parameter | Wert | +|---|---| +| `id` | Rezept-metaId (z.B. `"recipe/23431854_10968866"`) | + +**Response:** +``` +a00.r.r → "true" (String) +``` + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + ## Offene Punkte - Unlike-Endpoint (Service Worker blockiert Analyse) - Erinnerungen (reminder) – nur Premium-Account - Wiederholungen (repeat) – nur Premium-Account - Sortierung von Kategorien via API +- update_recipe (Rezept aktualisieren) – Endpoint: mprecipeput mit metaId +- mpadditemtolist (Zutaten aus Rezept → Einkaufsliste) diff --git a/pyproject.toml b/pyproject.toml index 981e25c..255482c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.5.2" +version = "0.6.0" description = "MCP server for Family Wall — read your family's lists and tasks via Claude" readme = "README.md" requires-python = ">=3.12" diff --git a/src/mcp_familywall/__init__.py b/src/mcp_familywall/__init__.py index 7225152..906d362 100644 --- a/src/mcp_familywall/__init__.py +++ b/src/mcp_familywall/__init__.py @@ -1 +1 @@ -__version__ = "0.5.2" +__version__ = "0.6.0" diff --git a/src/mcp_familywall/modules/recipes.py b/src/mcp_familywall/modules/recipes.py new file mode 100644 index 0000000..2a1a136 --- /dev/null +++ b/src/mcp_familywall/modules/recipes.py @@ -0,0 +1,120 @@ +"""Recipe helper functions for the Family Wall recipe box. + +Verified endpoints (2026-04-16 via FW_DEBUG=1): + - Create: POST mprecipeput — params use 'recipe.' prefix (recipe.name, etc.) + - Read all: POST metasync with id='recipe' — response at a00.r.r.updatedCreated[] + - Delete: POST metadelete with id= +""" + +from __future__ import annotations + +from typing import Any + + +def parse_recipe_summary(raw: dict[str, Any]) -> dict[str, Any]: + """Extract a compact recipe summary from a raw API recipe object. + + Args: + raw: Raw recipe dict from the API (metasync or mprecipeput response). + + Returns: + Dict with keys: id, name, prep_time_minutes, cook_time_minutes, serves, + description, can_delete. + """ + prep = raw.get("prepTime") + cook = raw.get("cookTime") + srv = raw.get("serves") + return { + "id": raw.get("metaId"), + "name": raw.get("name"), + "description": raw.get("description") or None, + "prep_time_minutes": int(prep) if prep else None, + "cook_time_minutes": int(cook) if cook else None, + "serves": int(srv) if srv else None, + "can_delete": raw.get("rights", {}).get("canDelete") == "true", + } + + +def parse_recipe_full(raw: dict[str, Any]) -> dict[str, Any]: + """Extract the full recipe from a raw API recipe object. + + Args: + raw: Raw recipe dict from the API (metasync or mprecipeput response). + + Returns: + Dict with all recipe fields including ingredients, instructions, etc. + """ + prep = raw.get("prepTime") + cook = raw.get("cookTime") + srv = raw.get("serves") + + # ingredientsList is auto-parsed by the server; normalise to a plain list of names. + ingredients_list_raw: list[dict[str, Any]] = raw.get("ingredientsList") or [] + ingredients_parsed = [item.get("name") for item in ingredients_list_raw if item.get("name")] + + return { + "id": raw.get("metaId"), + "name": raw.get("name"), + "description": raw.get("description") or None, + "ingredients": raw.get("ingredients") or None, + "ingredients_parsed": ingredients_parsed, + "instructions": raw.get("instructions") or None, + "prep_time_minutes": int(prep) if prep else None, + "cook_time_minutes": int(cook) if cook else None, + "serves": int(srv) if srv else None, + "url": raw.get("url") or None, + "is_favorite": raw.get("isFavorite") == "true", + "can_delete": raw.get("rights", {}).get("canDelete") == "true", + "can_update": raw.get("rights", {}).get("canUpdate") == "true", + "created_at": raw.get("creationDate"), + "account_id": raw.get("accountId"), + } + + +def build_create_params( + name: str, + description: str | None = None, + ingredients: str | None = None, + instructions: str | None = None, + prep_time_minutes: int | None = None, + cook_time_minutes: int | None = None, + serves: int | None = None, + url: str | None = None, +) -> dict[str, str]: + """Build the form parameters for a mprecipeput create call. + + The Family Wall API requires the 'recipe.' prefix for all recipe fields. + isRecipe='true' is always required. + + Args: + name: Recipe title (required). + description: Optional description. + ingredients: Optional free-text ingredients, lines separated by \\n. + instructions: Optional free-text cooking instructions, lines separated by \\n. + prep_time_minutes: Optional preparation time in minutes. + cook_time_minutes: Optional cooking time in minutes. + serves: Optional number of servings. + url: Optional external URL (e.g. original recipe source). + + Returns: + Dict of form parameters ready to send to mprecipeput. + """ + params: dict[str, str] = { + "recipe.name": name, + "recipe.isRecipe": "true", + } + if description is not None: + params["recipe.description"] = description + if ingredients is not None: + params["recipe.ingredients"] = ingredients + if instructions is not None: + params["recipe.instructions"] = instructions + if prep_time_minutes is not None: + params["recipe.prepTime"] = str(prep_time_minutes) + if cook_time_minutes is not None: + params["recipe.cookTime"] = str(cook_time_minutes) + if serves is not None: + params["recipe.serves"] = str(serves) + if url is not None: + params["recipe.url"] = url + return params diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index 73b09aa..95b1d8f 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -11,6 +11,11 @@ from mcp.server.fastmcp import FastMCP from mcp_familywall.auth import get_credentials from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError from mcp_familywall.modules.lists import translate_name +from mcp_familywall.modules.recipes import ( + build_create_params, + parse_recipe_full, + parse_recipe_summary, +) logger = logging.getLogger(__name__) @@ -532,9 +537,7 @@ def delete_category(category_id: str) -> str: except (KeyError, TypeError): raw_cats = [] - cat_obj = next( - (c for c in raw_cats if c.get("metaId") == category_id), None - ) + cat_obj = next((c for c in raw_cats if c.get("metaId") == category_id), None) if cat_obj is None: client.logout() return f"Error: Category '{category_id}' not found." @@ -547,7 +550,9 @@ def delete_category(category_id: str) -> str: "error": "System categories cannot be deleted.", "id": category_id, "name": cat_obj.get("name"), - "hint": "Only custom categories (custom=true in get_categories) can be deleted.", + "hint": ( + "Only custom categories (custom=true in get_categories) can be deleted." + ), }, ensure_ascii=False, indent=2, @@ -789,7 +794,10 @@ def update_task( and assignee_ids is None and list_id is None ): - return "Error: At least one of 'text', 'description', 'category_id', 'due_date', 'clear_due_date', 'assignee_ids', or 'list_id' must be provided." + return ( + "Error: At least one of 'text', 'description', 'category_id', 'due_date'," + " 'clear_due_date', 'assignee_ids', or 'list_id' must be provided." + ) params: dict[str, Any] = {"metaId": task_id} if text is not None: @@ -994,9 +1002,7 @@ def delete_list(list_id: str) -> str: except (KeyError, TypeError): raw_lists = [] - list_obj = next( - (lst for lst in raw_lists if lst.get("metaId") == list_id), None - ) + list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None) if list_obj is None: client.logout() return f"Error: List '{list_id}' not found." @@ -1115,6 +1121,222 @@ def like_post(post_id: str, like: bool = True) -> str: return json.dumps(result, ensure_ascii=False, indent=2) +# --------------------------------------------------------------------------- +# Helper: fetch all raw recipes +# --------------------------------------------------------------------------- + + +def _get_raw_recipes() -> list[dict[str, Any]]: + """Login, call metasync with id='recipe', logout and return the raw recipe list. + + Raises: + RuntimeError: On credential or API errors. + """ + data = _authenticated_call("metasync", {"id": "recipe"}) + try: + items = data["a00"]["r"]["r"]["updatedCreated"] + if isinstance(items, list): + return items # type: ignore[return-value] + except (KeyError, TypeError): + pass + return [] + + +# --------------------------------------------------------------------------- +# Tool: get_recipes +# --------------------------------------------------------------------------- + + +@mcp.tool() +def get_recipes() -> str: + """Return all family recipes as a compact JSON list. + + Returns: + JSON list of recipe summary objects with keys: id, name, description, + prep_time_minutes, cook_time_minutes, serves, can_delete. + Returns an error message string on failure. + """ + try: + raw_recipes = _get_raw_recipes() + except RuntimeError as exc: + return f"Error: {exc}" + + result = [parse_recipe_summary(r) for r in raw_recipes] + return json.dumps(result, ensure_ascii=False, indent=2) + + +# --------------------------------------------------------------------------- +# Tool: get_recipe +# --------------------------------------------------------------------------- + + +@mcp.tool() +def get_recipe(recipe_id: str) -> str: + """Return a single recipe in full detail. + + Args: + recipe_id: Recipe metaId from get_recipes + (e.g. ``"recipe/23431854_10968866"``). + + Returns: + JSON object with full recipe fields including ingredients, instructions, + ingredients_parsed, url, is_favorite, can_delete, can_update, created_at. + Returns an error message string on failure or when not found. + """ + try: + raw_recipes = _get_raw_recipes() + except RuntimeError as exc: + return f"Error: {exc}" + + raw = next((r for r in raw_recipes if r.get("metaId") == recipe_id), None) + if raw is None: + return f"Error: Recipe '{recipe_id}' not found." + + return json.dumps(parse_recipe_full(raw), ensure_ascii=False, indent=2) + + +# --------------------------------------------------------------------------- +# Tool: create_recipe +# --------------------------------------------------------------------------- + + +@mcp.tool() +def create_recipe( + name: str, + description: str | None = None, + ingredients: str | None = None, + instructions: str | None = None, + prep_time_minutes: int | None = None, + cook_time_minutes: int | None = None, + serves: int | None = None, + url: str | None = None, +) -> str: + """Create a new recipe in the Family Wall recipe box. + + IMPORTANT: Ask the user for confirmation before calling this tool. + + Args: + name: Recipe title (required). + description: Optional short description or teaser text. + ingredients: Optional ingredient list as free text. + Use newlines (``\\n``) to separate items. + Example: ``"200g Mehl\\n3 Eier\\n100ml Milch"`` + The server auto-parses this into a structured list. + instructions: Optional cooking instructions as free text. + Use newlines (``\\n``) to separate steps. + prep_time_minutes: Optional preparation time in minutes. + cook_time_minutes: Optional cooking/baking time in minutes. + serves: Optional number of servings. + url: Optional external URL (e.g. original recipe source). + + Returns: + JSON with the new recipe's full fields on success, or an error message. + """ + params = build_create_params( + name=name, + description=description, + ingredients=ingredients, + instructions=instructions, + prep_time_minutes=prep_time_minutes, + cook_time_minutes=cook_time_minutes, + serves=serves, + url=url, + ) + + try: + data = _authenticated_call("mprecipeput", params) + except RuntimeError as exc: + return f"Error: {exc}" + + try: + recipe_obj = data["a00"]["r"]["r"] + if not isinstance(recipe_obj, dict) or "metaId" not in recipe_obj: + raise TypeError("unexpected shape") + except (KeyError, TypeError): + return json.dumps( + {"warning": "Unexpected mprecipeput response structure", "raw": data}, + ensure_ascii=False, + indent=2, + ) + + result = parse_recipe_full(recipe_obj) + result["created"] = True + return json.dumps(result, ensure_ascii=False, indent=2) + + +# --------------------------------------------------------------------------- +# Tool: delete_recipe +# --------------------------------------------------------------------------- + + +@mcp.tool() +def delete_recipe(recipe_id: str) -> str: + """Permanently delete a recipe from the Family Wall recipe box. + + IMPORTANT: Ask the user for confirmation before calling this tool. + + Only recipes created by the current account (can_delete=true in + get_recipes output) can be deleted. The action cannot be undone. + + Args: + recipe_id: Recipe metaId from get_recipes + (e.g. ``"recipe/23431854_10968866"``). + + Returns: + JSON success indicator or an error message. + """ + # Verify the recipe exists and is deletable, then delete — single session. + try: + email, password = get_credentials() + except RuntimeError as exc: + return f"Error: {exc}" + + recipe_obj: dict[str, Any] | None = None + try: + with FamilyWallClient() as client: + client.login(email, password) + + # Fetch all recipes and verify the target can be deleted. + raw_data = client.call("metasync", {"id": "recipe"}) + try: + items: list[dict[str, Any]] = raw_data["a00"]["r"]["r"]["updatedCreated"] + except (KeyError, TypeError): + items = [] + + recipe_obj = next((r for r in items if r.get("metaId") == recipe_id), None) + if recipe_obj is None: + client.logout() + return f"Error: Recipe '{recipe_id}' not found." + + can_delete: str | None = (recipe_obj.get("rights") or {}).get("canDelete") + if can_delete != "true": + client.logout() + return json.dumps( + { + "error": "Recipe cannot be deleted.", + "id": recipe_id, + "name": recipe_obj.get("name"), + "hint": "Only recipes you created (can_delete=true) can be deleted.", + }, + ensure_ascii=False, + indent=2, + ) + + # Verified — delete in the same session. + client.call("metadelete", {"id": recipe_id}) + client.logout() + except FamilyWallError as exc: + return f"Error: Family Wall API error: {exc}" + except Exception as exc: + return f"Error: Connection error: {exc}" + + return json.dumps( + {"deleted": True, "id": recipe_id, "name": recipe_obj.get("name")}, + ensure_ascii=False, + indent=2, + ) + + # --------------------------------------------------------------------------- # Factory # ---------------------------------------------------------------------------