From bc28b09d492bcc87e3c6bcd0b9a668cb3100e8fd Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 16 Apr 2026 15:01:40 +0200 Subject: [PATCH] fix(recipes): normalize newlines + add update_recipe (v0.6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: literal backslash-n sequences in ingredients/instructions are now converted to real newline characters before sending to the API, so the server correctly splits ingredient lines into ingredientsList[]. New tool: update_recipe — partial update via mprecipeput with recipe.metaId; fetches current recipe in the same session to verify can_update and supply name fallback. Verified: recipe.metaId triggers update (not create). Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 9 +- README.md | 5 +- SPEC.md | 20 ++++ pyproject.toml | 2 +- src/mcp_familywall/__init__.py | 2 +- src/mcp_familywall/modules/recipes.py | 97 +++++++++++++++++- src/mcp_familywall/server.py | 137 ++++++++++++++++++++++++++ 7 files changed, 260 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cba2771..2351494 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden. ## Aktueller Stand -### Implementierte Tools (v0.6.0) +### Implementierte Tools (v0.6.1) | Kategorie | Tools | |---|---| @@ -33,7 +33,7 @@ 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` | +| Rezepte | `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe` | ## Roadmap @@ -41,8 +41,8 @@ und wird in Claude Desktop eingebunden. - 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 ✓ -- v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓ ← aktuell -- v0.6.1: update_recipe (Rezept bearbeiten) +- v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓ +- v0.6.1: update_recipe + Bugfix Zeilenumbrüche in create_recipe ✓ ← aktuell - v0.6.2: mpadditemtolist (Zutaten → Einkaufsliste) - v0.5.3: update_list (Umbenennen, emoji/color ändern), Sharing-Verwaltung - v0.7.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) @@ -133,6 +133,7 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level: | `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! | +| `mprecipeput` (Update) | zusätzlich `recipe.metaId` | Vorhandene ID → Update statt Create | | `metasync` (Rezepte lesen) | `id="recipe"` | liefert `a00.r.r.updatedCreated[]` | ### Self-Like-Restriction diff --git a/README.md b/README.md index c3d3516..256aed9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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.6.0) +## Features (v0.6.1) ### Read @@ -26,7 +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) +- `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url); use `\n` to separate ingredient lines +- `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged) - `delete_recipe` -- permanently delete a recipe (only own recipes) ## Requirements diff --git a/SPEC.md b/SPEC.md index 3732c10..54fa33d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -432,6 +432,26 @@ a00.r.r → vollständiges Rezept-Objekt **Verifiziert am:** 2026-04-16 via FW_DEBUG=1 +### `mprecipeput` – Rezept aktualisieren (Update) +POST https://api.familywall.com/api/mprecipeput + +Identisch zum Create-Aufruf, aber mit zusätzlichem `recipe.metaId`-Parameter. +Der Server unterscheidet Create vs. Update anhand ob `recipe.metaId` vorhanden ist. +Nur geänderte Felder müssen mitgeschickt werden (Partial Update). +`recipe.name` und `recipe.isRecipe="true"` sollten immer mitgeschickt werden. + +**Zusätzlicher Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `recipe.metaId` | ja (für Update) | metaId des zu aktualisierenden Rezepts | + +**Newline-Hinweis:** Zutaten und Anleitung müssen echte `\n`-Zeichen enthalten +(nicht die zwei-Zeichen-Sequenz `\n`). Literale Backslash-n werden vom Server +als ein Element interpretiert → kein Splitting in `ingredientsList`. + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + ### `metasync` (id='recipe') – Alle Rezepte abrufen POST https://api.familywall.com/api/metasync diff --git a/pyproject.toml b/pyproject.toml index 255482c..ff11818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.6.0" +version = "0.6.1" 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 906d362..43c4ab0 100644 --- a/src/mcp_familywall/__init__.py +++ b/src/mcp_familywall/__init__.py @@ -1 +1 @@ -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/src/mcp_familywall/modules/recipes.py b/src/mcp_familywall/modules/recipes.py index 2a1a136..2aeed10 100644 --- a/src/mcp_familywall/modules/recipes.py +++ b/src/mcp_familywall/modules/recipes.py @@ -2,8 +2,14 @@ Verified endpoints (2026-04-16 via FW_DEBUG=1): - Create: POST mprecipeput — params use 'recipe.' prefix (recipe.name, etc.) + - Update: POST mprecipeput with recipe.metaId — same prefix convention - Read all: POST metasync with id='recipe' — response at a00.r.r.updatedCreated[] - Delete: POST metadelete with id= + +Newline normalisation (bug fix v0.6.1): + LLM tool calls may pass literal backslash-n ('\\n', two chars) instead of a + real newline character. _normalize_newlines() converts them before sending + to the API so the server splits ingredients/instructions correctly. """ from __future__ import annotations @@ -11,6 +17,23 @@ from __future__ import annotations from typing import Any +def _normalize_newlines(text: str) -> str: + """Replace literal backslash-n sequences with real newline characters. + + When an LLM generates a tool call it may produce ``"line1\\nline2"`` + (two chars: backslash + n) instead of ``"line1\\nline2"`` (real newline). + The Family Wall API splits ingredients and instructions on real newlines + only, so we normalise before sending. + + Args: + text: Free-text string that may contain literal ``\\n`` sequences. + + Returns: + The same string with every ``\\n`` replaced by a real ``\\n``. + """ + return text.replace("\\n", "\n") + + def parse_recipe_summary(raw: dict[str, Any]) -> dict[str, Any]: """Extract a compact recipe summary from a raw API recipe object. @@ -86,11 +109,17 @@ def build_create_params( The Family Wall API requires the 'recipe.' prefix for all recipe fields. isRecipe='true' is always required. + Newline normalisation is applied to ingredients and instructions so that + literal ``\\n`` sequences sent by an LLM are converted to real newlines, + which the server uses to split the ingredient list. + 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. + ingredients: Optional free-text ingredients, lines separated by ``\\n``. + Literal backslash-n sequences are normalised to real newlines. + instructions: Optional free-text cooking instructions, lines separated + by ``\\n``. Literal backslash-n sequences are normalised. prep_time_minutes: Optional preparation time in minutes. cook_time_minutes: Optional cooking time in minutes. serves: Optional number of servings. @@ -106,9 +135,69 @@ def build_create_params( if description is not None: params["recipe.description"] = description if ingredients is not None: - params["recipe.ingredients"] = ingredients + params["recipe.ingredients"] = _normalize_newlines(ingredients) if instructions is not None: - params["recipe.instructions"] = instructions + params["recipe.instructions"] = _normalize_newlines(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 + + +def build_update_params( + recipe_id: str, + current_name: str, + name: str | None = None, + 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 update call. + + Identical to :func:`build_create_params` except that ``recipe.metaId`` is + included so the API updates the existing recipe instead of creating a new one. + ``recipe.name`` and ``recipe.isRecipe`` are always present — the name falls + back to *current_name* when the caller does not provide a new one. + + Newline normalisation is applied to ingredients and instructions (see + :func:`_normalize_newlines`). + + Args: + recipe_id: metaId of the recipe to update (e.g. ``"recipe/123_456"``). + current_name: The recipe's existing name, used as fallback when *name* + is not provided. Fetched from the API before calling this function. + name: New recipe title (omit to keep existing). + description: New description (omit to keep existing). + ingredients: New ingredients text (omit to keep existing). + instructions: New instructions text (omit to keep existing). + prep_time_minutes: New preparation time in minutes (omit to keep existing). + cook_time_minutes: New cooking time in minutes (omit to keep existing). + serves: New number of servings (omit to keep existing). + url: New external URL (omit to keep existing). + + Returns: + Dict of form parameters ready to send to mprecipeput. + """ + params: dict[str, str] = { + "recipe.metaId": recipe_id, + "recipe.name": name if name is not None else current_name, + "recipe.isRecipe": "true", + } + if description is not None: + params["recipe.description"] = description + if ingredients is not None: + params["recipe.ingredients"] = _normalize_newlines(ingredients) + if instructions is not None: + params["recipe.instructions"] = _normalize_newlines(instructions) if prep_time_minutes is not None: params["recipe.prepTime"] = str(prep_time_minutes) if cook_time_minutes is not None: diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index 95b1d8f..2839cae 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -13,6 +13,7 @@ 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, + build_update_params, parse_recipe_full, parse_recipe_summary, ) @@ -1337,6 +1338,142 @@ def delete_recipe(recipe_id: str) -> str: ) +# --------------------------------------------------------------------------- +# Tool: update_recipe +# --------------------------------------------------------------------------- + + +@mcp.tool() +def update_recipe( + recipe_id: str, + name: str | None = None, + 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: + """Update an existing recipe in the Family Wall recipe box. + + IMPORTANT: Ask the user for confirmation before calling this tool. + + At least one field besides *recipe_id* must be provided. Fields that are + omitted are left unchanged on the server. + + Args: + recipe_id: Recipe metaId from get_recipes + (e.g. ``"recipe/23431854_10968866"``). + name: New recipe title (omit to keep existing). + description: New description (omit to keep existing). + ingredients: New ingredient list as free text (omit to keep existing). + Use newlines (``\\n``) to separate items. + Example: ``"200g Mehl\\n3 Eier\\n100ml Milch"`` + instructions: New cooking instructions as free text (omit to keep + existing). Use newlines (``\\n``) to separate steps. + prep_time_minutes: New preparation time in minutes (omit to keep + existing). + cook_time_minutes: New cooking/baking time in minutes (omit to keep + existing). + serves: New number of servings (omit to keep existing). + url: New external URL (omit to keep existing). + + Returns: + JSON with the updated recipe's full fields on success, or an error + message. + """ + if all( + v is None + for v in ( + name, + description, + ingredients, + instructions, + prep_time_minutes, + cook_time_minutes, + serves, + url, + ) + ): + return ( + "Error: At least one of 'name', 'description', 'ingredients', 'instructions'," + " 'prep_time_minutes', 'cook_time_minutes', 'serves', or 'url' must be provided." + ) + + # Single session: fetch current recipe (verify + get name fallback) → update. + try: + email, password = get_credentials() + except RuntimeError as exc: + return f"Error: {exc}" + + try: + with FamilyWallClient() as client: + client.login(email, password) + + # Fetch all recipes to verify the target exists and is updatable. + raw_data = client.call("metasync", {"id": "recipe"}) + try: + items: list[dict[str, Any]] = raw_data["a00"]["r"]["r"]["updatedCreated"] + except (KeyError, TypeError): + items = [] + + current = next((r for r in items if r.get("metaId") == recipe_id), None) + if current is None: + client.logout() + return f"Error: Recipe '{recipe_id}' not found." + + can_update: str | None = (current.get("rights") or {}).get("canUpdate") + if can_update != "true": + client.logout() + return json.dumps( + { + "error": "Recipe cannot be updated.", + "id": recipe_id, + "name": current.get("name"), + "hint": "Only recipes you created (can_update=true) can be updated.", + }, + ensure_ascii=False, + indent=2, + ) + + # Build params — current name is used as fallback when caller omits name. + params = build_update_params( + recipe_id=recipe_id, + current_name=current.get("name", ""), + name=name, + description=description, + ingredients=ingredients, + instructions=instructions, + prep_time_minutes=prep_time_minutes, + cook_time_minutes=cook_time_minutes, + serves=serves, + url=url, + ) + + resp = client.call("mprecipeput", params) + client.logout() + except FamilyWallError as exc: + return f"Error: Family Wall API error: {exc}" + except Exception as exc: + return f"Error: Connection error: {exc}" + + try: + recipe_obj = resp["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": resp}, + ensure_ascii=False, + indent=2, + ) + + result = parse_recipe_full(recipe_obj) + result["updated"] = True + return json.dumps(result, ensure_ascii=False, indent=2) + + # --------------------------------------------------------------------------- # Factory # ---------------------------------------------------------------------------