fix(recipes): normalize newlines + add update_recipe (v0.6.1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:01:40 +02:00
parent ebbbf38ab9
commit bc28b09d49
7 changed files with 260 additions and 12 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.0"
__version__ = "0.6.1"
+93 -4
View File
@@ -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=<recipe_metaId>
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:
+137
View File
@@ -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
# ---------------------------------------------------------------------------