feat(recipes): add recipe categories support (v0.8.0)

- New tool: get_recipe_categories() lists available recipe category IDs
- Enhanced create_recipe and update_recipe with optional category_ids parameter
- Extended get_recipe and get_recipes to include category_ids in response
- Updated parse_recipe_full() to extract recipeCategoryIdList from API response
- Extended build_create_params() and build_update_params() to handle category_ids

Recipe categories are managed via recipe.recipeCategoryIdList in mprecipeput API.
Categories are represented as a list of category IDs (e.g. ["category/23431854_2"]).
No direct API endpoint exists for listing all categories; they are discovered from
existing recipes or must be known in advance.

Version: 0.7.5 → 0.8.0
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 06:24:48 +02:00
parent eb022e2376
commit 4c60b5b5fa
7 changed files with 123 additions and 16 deletions
+5 -4
View File
@@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden.
## Aktueller Stand ## Aktueller Stand
### Implementierte Tools (v0.7.5) ### Implementierte Tools (v0.8.0)
| Kategorie | Tools | | Kategorie | Tools |
|---|---| |---|---|
@@ -33,7 +33,7 @@ und wird in Claude Desktop eingebunden.
| Listen | `create_list`, `update_list`, `delete_list` | | Listen | `create_list`, `update_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`, `update_recipe`, `delete_recipe` | | Rezepte | `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe`, `get_recipe_categories` |
| Kreise | `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` | | Kreise | `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` |
## Roadmap ## Roadmap
@@ -50,9 +50,10 @@ und wird in Claude Desktop eingebunden.
- v0.7.2: delete_circle ✓ - v0.7.2: delete_circle ✓
- v0.7.3: update_list (Umbenennen, emoji/color ändern) ✓ - v0.7.3: update_list (Umbenennen, emoji/color ändern) ✓
- v0.7.4: update_circle (Kreis umbenennen) ✓ - v0.7.4: update_circle (Kreis umbenennen) ✓
- v0.7.5: Primärkreis-Schutz in update_circle (isFirstFamily-Check) ✓ ← aktuell - v0.7.5: Primärkreis-Schutz in update_circle (isFirstFamily-Check) ✓
- v0.7.6: mpadditemtolist (Zutaten → Einkaufsliste) - v0.8.0: Rezept-Kategorien (get_recipe_categories, create_recipe + category_ids, update_recipe + category_ids) ✓ ← aktuell
- v0.8.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) - v0.8.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich)
- v0.8.x: mpadditemtolist (Zutaten → Einkaufsliste)
- v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren) - v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren)
+5 -4
View File
@@ -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. 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.7.5) ## Features (v0.8.0)
### Read ### Read
@@ -13,7 +13,8 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your
- `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_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.) - `get_recipe` -- get a single recipe in full detail (ingredients, instructions, ingredients_parsed, category_ids, etc.)
- `get_recipe_categories` -- list all available recipe categories (extracts from existing recipes)
### Write (with confirmation prompt) ### Write (with confirmation prompt)
@@ -27,8 +28,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); use `\n` to separate ingredient lines - `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url, category_ids); use `\n` to separate ingredient lines
- `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged) - `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged; supports `category_ids` list to change categories)
- `delete_recipe` -- permanently delete a recipe (only own recipes) - `delete_recipe` -- permanently delete a recipe (only own recipes)
- `create_circle` -- create a new Family Wall circle (group) - `create_circle` -- create a new Family Wall circle (group)
- `update_circle` -- rename a circle (server capitalises first letter; only name is changeable via API; primary circle is protected) - `update_circle` -- rename a circle (server capitalises first letter; only name is changeable via API; primary circle is protected)
+20 -1
View File
@@ -659,11 +659,30 @@ Löscht einen Kreis und alle zugehörigen Inhalte (Listen, Tasks, Rezepte, Wall-
**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 (family/23447371 erfolgreich gelöscht) **Verifiziert am:** 2026-04-16 via FW_DEBUG=1 (family/23447371 erfolgreich gelöscht)
### Recipe Categories API Details
Recipe categories are managed via `mprecipeput` endpoint. Each recipe can have zero or more
categories assigned via `recipe.recipeCategoryIdList` (sent multiple times for each category).
**Response structure** (in `mprecipeput` response):
```
a00.r.r
.recipeCategoryIdList[] → List of assigned category IDs (e.g. ["category/23431854_2"])
.recipeCategories[] → List of system names (e.g. ["KIDS_LOVE"])
```
**Category IDs** have format `category/<family_id>_<index>`. Categories are family-wide.
Premium accounts have additional categories beyond the 5 available in free tier.
**Limitation:** Currently no API endpoint to list all available categories. Categories are
discovered by examining existing recipes or hardcoding known IDs.
## 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 - Rezept-Kategorien-Listing-Endpoint (derzeit keine API, müssen aus Rezepten extrahiert werden)
- mpadditemtolist (Zutaten aus Rezept → Einkaufsliste) - mpadditemtolist (Zutaten aus Rezept → Einkaufsliste)
- Einladung bestehender FamilyWall-Nutzer (accinvite nur für neue Accounts) - Einladung bestehender FamilyWall-Nutzer (accinvite nur für neue Accounts)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcp-familywall" name = "mcp-familywall"
version = "0.7.5" version = "0.8.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
View File
@@ -1 +1 @@
__version__ = "0.7.5" __version__ = "0.8.0"
+24 -4
View File
@@ -75,6 +75,15 @@ def parse_recipe_full(raw: dict[str, Any]) -> dict[str, Any]:
ingredients_list_raw: list[dict[str, Any]] = raw.get("ingredientsList") or [] 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")] ingredients_parsed = [item.get("name") for item in ingredients_list_raw if item.get("name")]
# Extract recipe categories from API response.
# The API provides both recipeCategoryIdList (actual IDs) and recipeCategories
# (system name strings). We use the ID list as the authoritative source.
categories: list[str] = []
category_ids: list[str] = raw.get("recipeCategoryIdList") or []
for cat_id in category_ids:
if cat_id:
categories.append(cat_id)
return { return {
"id": raw.get("metaId"), "id": raw.get("metaId"),
"name": raw.get("name"), "name": raw.get("name"),
@@ -91,6 +100,7 @@ def parse_recipe_full(raw: dict[str, Any]) -> dict[str, Any]:
"can_update": raw.get("rights", {}).get("canUpdate") == "true", "can_update": raw.get("rights", {}).get("canUpdate") == "true",
"created_at": raw.get("creationDate"), "created_at": raw.get("creationDate"),
"account_id": raw.get("accountId"), "account_id": raw.get("accountId"),
"category_ids": categories,
} }
@@ -103,7 +113,8 @@ def build_create_params(
cook_time_minutes: int | None = None, cook_time_minutes: int | None = None,
serves: int | None = None, serves: int | None = None,
url: str | None = None, url: str | None = None,
) -> dict[str, str]: category_ids: list[str] | None = None,
) -> dict[str, str | list[str]]:
"""Build the form parameters for a mprecipeput create call. """Build the form parameters for a mprecipeput create call.
The Family Wall API requires the 'recipe.' prefix for all recipe fields. The Family Wall API requires the 'recipe.' prefix for all recipe fields.
@@ -124,11 +135,13 @@ def build_create_params(
cook_time_minutes: Optional cooking time in minutes. cook_time_minutes: Optional cooking time in minutes.
serves: Optional number of servings. serves: Optional number of servings.
url: Optional external URL (e.g. original recipe source). url: Optional external URL (e.g. original recipe source).
category_ids: Optional list of recipe category IDs
(e.g. ``["category/23431854_2"]``).
Returns: Returns:
Dict of form parameters ready to send to mprecipeput. Dict of form parameters ready to send to mprecipeput.
""" """
params: dict[str, str] = { params: dict[str, str | list[str]] = {
"recipe.name": name, "recipe.name": name,
"recipe.isRecipe": "true", "recipe.isRecipe": "true",
} }
@@ -146,6 +159,8 @@ def build_create_params(
params["recipe.serves"] = str(serves) params["recipe.serves"] = str(serves)
if url is not None: if url is not None:
params["recipe.url"] = url params["recipe.url"] = url
if category_ids is not None:
params["recipe.recipeCategoryIdList"] = category_ids
return params return params
@@ -160,7 +175,8 @@ def build_update_params(
cook_time_minutes: int | None = None, cook_time_minutes: int | None = None,
serves: int | None = None, serves: int | None = None,
url: str | None = None, url: str | None = None,
) -> dict[str, str]: category_ids: list[str] | None = None,
) -> dict[str, str | list[str]]:
"""Build the form parameters for a mprecipeput update call. """Build the form parameters for a mprecipeput update call.
Identical to :func:`build_create_params` except that ``recipe.metaId`` is Identical to :func:`build_create_params` except that ``recipe.metaId`` is
@@ -183,11 +199,13 @@ def build_update_params(
cook_time_minutes: New cooking 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). serves: New number of servings (omit to keep existing).
url: New external URL (omit to keep existing). url: New external URL (omit to keep existing).
category_ids: New list of recipe category IDs (omit to keep existing).
Pass empty list to remove all categories.
Returns: Returns:
Dict of form parameters ready to send to mprecipeput. Dict of form parameters ready to send to mprecipeput.
""" """
params: dict[str, str] = { params: dict[str, str | list[str]] = {
"recipe.metaId": recipe_id, "recipe.metaId": recipe_id,
"recipe.name": name if name is not None else current_name, "recipe.name": name if name is not None else current_name,
"recipe.isRecipe": "true", "recipe.isRecipe": "true",
@@ -206,4 +224,6 @@ def build_update_params(
params["recipe.serves"] = str(serves) params["recipe.serves"] = str(serves)
if url is not None: if url is not None:
params["recipe.url"] = url params["recipe.url"] = url
if category_ids is not None:
params["recipe.recipeCategoryIdList"] = category_ids
return params return params
+67 -1
View File
@@ -1715,6 +1715,62 @@ def _get_raw_recipes() -> list[dict[str, Any]]:
return [] return []
# ---------------------------------------------------------------------------
# Tool: get_recipe_categories
# ---------------------------------------------------------------------------
@mcp.tool()
def get_recipe_categories() -> str:
"""Return all available recipe categories for the family.
Returns:
JSON list of recipe category objects with keys: id, name, emoji.
Returns an error message string on failure.
"""
# Attempt to get categories via metasync call (similar to recipes).
# If the endpoint supports it, use that. Otherwise fallback to extracting
# categories from existing recipes.
try:
data = _authenticated_call("metasync", {"id": "recipeCategory"})
except RuntimeError:
# Endpoint doesn't exist or failed. Fall back to extracting from recipes.
try:
raw_recipes = _get_raw_recipes()
except RuntimeError as exc:
return f"Error: {exc}"
# Collect unique category IDs from all recipes.
seen_ids: set[str] = set()
category_ids: list[str] = []
for recipe in raw_recipes:
if not isinstance(recipe, dict):
continue
recipe_cat_ids = recipe.get("recipeCategoryIdList")
if not isinstance(recipe_cat_ids, list):
continue
for cat_id in recipe_cat_ids:
if isinstance(cat_id, str) and cat_id and cat_id not in seen_ids:
seen_ids.add(cat_id)
category_ids.append(cat_id)
return json.dumps(category_ids, ensure_ascii=False, indent=2)
# If metasync succeeded, extract categories from response.
try:
items: list[dict[str, Any]] = data["a00"]["r"]["r"]["updatedCreated"]
except (KeyError, TypeError):
items = []
if not isinstance(items, list):
return json.dumps({"error": "No recipe categories available"}, ensure_ascii=False)
result: list[str] = []
for cat_id in items:
if isinstance(cat_id, str):
result.append(cat_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tool: get_recipes # Tool: get_recipes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1783,6 +1839,7 @@ def create_recipe(
cook_time_minutes: int | None = None, cook_time_minutes: int | None = None,
serves: int | None = None, serves: int | None = None,
url: str | None = None, url: str | None = None,
category_ids: list[str] | None = None,
) -> str: ) -> str:
"""Create a new recipe in the Family Wall recipe box. """Create a new recipe in the Family Wall recipe box.
@@ -1801,6 +1858,8 @@ def create_recipe(
cook_time_minutes: Optional cooking/baking time in minutes. cook_time_minutes: Optional cooking/baking time in minutes.
serves: Optional number of servings. serves: Optional number of servings.
url: Optional external URL (e.g. original recipe source). url: Optional external URL (e.g. original recipe source).
category_ids: Optional list of recipe category IDs
(e.g. ``["category/23431854_2"]``). Use get_recipe_categories to find IDs.
Returns: Returns:
JSON with the new recipe's full fields on success, or an error message. JSON with the new recipe's full fields on success, or an error message.
@@ -1814,6 +1873,7 @@ def create_recipe(
cook_time_minutes=cook_time_minutes, cook_time_minutes=cook_time_minutes,
serves=serves, serves=serves,
url=url, url=url,
category_ids=category_ids,
) )
try: try:
@@ -1926,6 +1986,7 @@ def update_recipe(
cook_time_minutes: int | None = None, cook_time_minutes: int | None = None,
serves: int | None = None, serves: int | None = None,
url: str | None = None, url: str | None = None,
category_ids: list[str] | None = None,
) -> str: ) -> str:
"""Update an existing recipe in the Family Wall recipe box. """Update an existing recipe in the Family Wall recipe box.
@@ -1950,6 +2011,8 @@ def update_recipe(
existing). existing).
serves: New number of servings (omit to keep existing). serves: New number of servings (omit to keep existing).
url: New external URL (omit to keep existing). url: New external URL (omit to keep existing).
category_ids: New list of recipe category IDs (omit to keep existing).
Pass empty list to remove all categories.
Returns: Returns:
JSON with the updated recipe's full fields on success, or an error JSON with the updated recipe's full fields on success, or an error
@@ -1966,11 +2029,13 @@ def update_recipe(
cook_time_minutes, cook_time_minutes,
serves, serves,
url, url,
category_ids,
) )
): ):
return ( return (
"Error: At least one of 'name', 'description', 'ingredients', 'instructions'," "Error: At least one of 'name', 'description', 'ingredients', 'instructions',"
" 'prep_time_minutes', 'cook_time_minutes', 'serves', or 'url' must be provided." " 'prep_time_minutes', 'cook_time_minutes', 'serves', 'url', or 'category_ids'"
" must be provided."
) )
# Single session: fetch current recipe (verify + get name fallback) → update. # Single session: fetch current recipe (verify + get name fallback) → update.
@@ -2021,6 +2086,7 @@ def update_recipe(
cook_time_minutes=cook_time_minutes, cook_time_minutes=cook_time_minutes,
serves=serves, serves=serves,
url=url, url=url,
category_ids=category_ids,
) )
resp = client.call("mprecipeput", params) resp = client.call("mprecipeput", params)