feat(recipes): implement get_recipes, get_recipe, create_recipe, delete_recipe (v0.6.0)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden.
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
### Implementierte Tools (v0.5.x)
|
### Implementierte Tools (v0.6.0)
|
||||||
|
|
||||||
| Kategorie | Tools |
|
| Kategorie | Tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,15 +33,19 @@ und wird in Claude Desktop eingebunden.
|
|||||||
| Listen | `create_list`, `delete_list` |
|
| Listen | `create_list`, `delete_list` |
|
||||||
| Kategorien | `create_category`, `delete_category` |
|
| Kategorien | `create_category`, `delete_category` |
|
||||||
| Aktivitäten | `like_post` |
|
| Aktivitäten | `like_post` |
|
||||||
|
| Rezepte | `get_recipes`, `get_recipe`, `create_recipe`, `delete_recipe` |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- v0.4.x: Kategorie-Management, Task-Felder (due_date, assignee, list_id) ✓
|
- v0.4.x: Kategorie-Management, Task-Felder (due_date, assignee, list_id) ✓
|
||||||
- v0.5.x: Listen-Management (create_list, delete_list) ✓
|
- v0.5.x: Listen-Management (create_list, delete_list) ✓
|
||||||
- v0.5.1: emoji + color in get_lists / create_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.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)
|
- 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` | `metaId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee`, `taskListId` | – |
|
||||||
| `taskupdate2` | `dueDate` löschen | `$empty` |
|
| `taskupdate2` | `dueDate` löschen | `$empty` |
|
||||||
| `taskmark` | `taskId`, `complete` | `"true"`/`"false"` |
|
| `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 |
|
| `wallmood` | `wall_message_id`, `moodType` | `"STAR"` für Like |
|
||||||
| `taskcategoryput` | `name`, `emoji` | – |
|
| `taskcategoryput` | `name`, `emoji` | – |
|
||||||
| `taskcategorydelete` | `id` | metaId der Kategorie |
|
| `taskcategorydelete` | `id` | metaId der Kategorie |
|
||||||
| `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji` | `taskListType`: `"SHOPPING_LIST"`/`"TODOS"` |
|
| `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji` | `taskListType`: `"SHOPPING_LIST"`/`"TODOS"` |
|
||||||
| `taskdeletelist` | `id` | metaId der Liste |
|
| `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
|
### Self-Like-Restriction
|
||||||
Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts.
|
Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# mcp-familywall
|
# 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
|
### 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_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_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_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)
|
### 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)
|
- `create_category` -- create a custom category for a shopping list (with optional icon)
|
||||||
- `delete_category` -- delete a custom category (system categories are protected)
|
- `delete_category` -- delete a custom category (system categories are protected)
|
||||||
- `like_post` -- like a wall post/activity
|
- `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
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -387,9 +387,95 @@ Operationen.
|
|||||||
zeigen nicht den echten Request-Body für diese Calls.
|
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.
|
**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/<id>"
|
||||||
|
.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
|
## Offene Punkte
|
||||||
|
|
||||||
- Unlike-Endpoint (Service Worker blockiert Analyse)
|
- Unlike-Endpoint (Service Worker blockiert Analyse)
|
||||||
- Erinnerungen (reminder) – nur Premium-Account
|
- Erinnerungen (reminder) – nur Premium-Account
|
||||||
- Wiederholungen (repeat) – nur Premium-Account
|
- Wiederholungen (repeat) – nur Premium-Account
|
||||||
- Sortierung von Kategorien via API
|
- Sortierung von Kategorien via API
|
||||||
|
- update_recipe (Rezept aktualisieren) – Endpoint: mprecipeput mit metaId
|
||||||
|
- mpadditemtolist (Zutaten aus Rezept → Einkaufsliste)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mcp-familywall"
|
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"
|
description = "MCP server for Family Wall — read your family's lists and tasks via Claude"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.5.2"
|
__version__ = "0.6.0"
|
||||||
|
|||||||
@@ -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=<recipe_metaId>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
@@ -11,6 +11,11 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
from mcp_familywall.auth import get_credentials
|
from mcp_familywall.auth import get_credentials
|
||||||
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
|
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
|
||||||
from mcp_familywall.modules.lists import translate_name
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -532,9 +537,7 @@ def delete_category(category_id: str) -> str:
|
|||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
raw_cats = []
|
raw_cats = []
|
||||||
|
|
||||||
cat_obj = next(
|
cat_obj = next((c for c in raw_cats if c.get("metaId") == category_id), None)
|
||||||
(c for c in raw_cats if c.get("metaId") == category_id), None
|
|
||||||
)
|
|
||||||
if cat_obj is None:
|
if cat_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: Category '{category_id}' not found."
|
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.",
|
"error": "System categories cannot be deleted.",
|
||||||
"id": category_id,
|
"id": category_id,
|
||||||
"name": cat_obj.get("name"),
|
"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,
|
ensure_ascii=False,
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -789,7 +794,10 @@ def update_task(
|
|||||||
and assignee_ids is None
|
and assignee_ids is None
|
||||||
and list_id 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}
|
params: dict[str, Any] = {"metaId": task_id}
|
||||||
if text is not None:
|
if text is not None:
|
||||||
@@ -994,9 +1002,7 @@ def delete_list(list_id: str) -> str:
|
|||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
raw_lists = []
|
raw_lists = []
|
||||||
|
|
||||||
list_obj = next(
|
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
||||||
(lst for lst in raw_lists if lst.get("metaId") == list_id), None
|
|
||||||
)
|
|
||||||
if list_obj is None:
|
if list_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: List '{list_id}' not found."
|
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)
|
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
|
# Factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user