From abb557e96b4f5f5fc822fac949aced509840f0bd Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 16 Apr 2026 18:10:05 +0200 Subject: [PATCH] fix(lists): circle scope support for get_lists, create_list, delete_list (v0.7.1) - get_lists(scope): API scope parameter now used server-side; accepts circle metaId ("family/XXXX") or circle name; returns circle_id field per list - create_list(circle_id): new optional param; passes as API scope param - delete_list: derives circle from list metaId and passes scope for secondary-circle lists - Added _circle_id_from_list_id() helper (taskList/FAMNUM_LISTNUM -> family/FAMNUM) - SPEC.md: documented scope param for taskgettasklists, taskcreatelist, taskdeletelist - Verified: familyId/circleId/id params ignored by API, only scope works Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 20 ++++--- README.md | 6 +- SPEC.md | 34 +++++++++-- pyproject.toml | 2 +- src/mcp_familywall/__init__.py | 2 +- src/mcp_familywall/server.py | 106 ++++++++++++++++++++++++++++++--- 6 files changed, 144 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cf3ce6..7c58cfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden. ## Aktueller Stand -### Implementierte Tools (v0.7.0) +### Implementierte Tools (v0.7.1) | Kategorie | Tools | |---|---| @@ -45,8 +45,9 @@ und wird in Claude Desktop eingebunden. - v0.5.3: Kategorie-Auto-Assign-Hinweis im create_task Docstring ✓ (nachgeliefert in v0.6.1) - v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓ - v0.6.1: update_recipe + Bugfix Zeilenumbrüche in create_recipe ✓ -- v0.7.0: create_circle + add_member_to_circle ✓ ← aktuell -- v0.7.1: mpadditemtolist (Zutaten → Einkaufsliste) +- v0.7.0: create_circle + add_member_to_circle ✓ +- v0.7.1: get_lists scope fix + create_list circle_id + delete_list scope ✓ ← aktuell +- v0.7.2: mpadditemtolist (Zutaten → Einkaufsliste) - v0.5.3: update_list (Umbenennen, emoji/color ändern), Sharing-Verwaltung - v0.8.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) - v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren) @@ -62,8 +63,12 @@ um unnötige HTTP-Roundtrips zu vermeiden. Credentials liegen im OS Keyring ### Kreise (Scopes) Family Wall kennt mehrere Kreise (z.B. Familie, erweiterter Familienkreis). -`get_lists` unterstützt optionalen `scope`-Parameter zur Filterung. -Ohne `scope` werden alle Kreise zurückgegeben. +Der API-Parameter `scope=family/XXXX` schaltet den Server-Kontext um. +- `taskgettasklists` ohne scope → primärer Kreis; mit scope → angegebener Kreis +- `taskcreatelist` mit scope → neue Liste im angegebenen Kreis +- `taskdeletelist` mit scope → löscht Liste aus angegebenem Kreis +- `get_lists(scope="family/XXXX")` oder `get_lists(scope="Kreis-Name")` zur Filterung +- Die Listen-metaId kodiert den Kreis: `taskList/_` → `family/` ### Listen-Namen Systembezeichnungen (z.B. `SYS-CAT-SHOPPINGLIST`) werden in deutsche @@ -133,8 +138,9 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level: | `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 | +| `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji`, `scope` | `scope`: Kreis-metaId für nicht-primäre Kreise | +| `taskgettasklists` | `scope` | Kreis-metaId; ohne scope → primärer Kreis | +| `taskdeletelist` | `id`, `scope` | `scope`: Kreis-metaId für sekundäre Kreise | | `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[]` | diff --git a/README.md b/README.md index db41b62..3469327 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 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.0) +## Features (v0.7.1) ### Read - `get_circles` -- list all family circles - `get_members` -- list members of a circle (or all circles) -- `get_lists` -- list all task lists (includes `emoji` and `color`; `null` when unset) +- `get_lists` -- list all task lists (includes `emoji`, `color`, `circle_id`; `null` when unset); optional `scope` parameter filters by circle metaId or circle name - `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) @@ -21,7 +21,7 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your - `update_task` -- update text, description, category, due date, assignees, or move to a different list; supports `clear_due_date=True` to remove a due date - `toggle_task` -- mark a task complete or reopen it - `delete_task` -- permanently delete a task -- `create_list` -- create a new task list (SHOPPING_LIST or TODOS; optional `emoji` and `color`) +- `create_list` -- create a new task list (SHOPPING_LIST or TODOS; optional `emoji`, `color`, and `circle_id` to target a specific circle) - `delete_list` -- permanently delete a list and all its tasks (system lists are protected) - `create_category` -- create a custom category for a shopping list (with optional icon) - `delete_category` -- delete a custom category (system categories are protected) diff --git a/SPEC.md b/SPEC.md index 0cf29ee..2068b2d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -292,6 +292,13 @@ POST https://api.familywall.com/api/taskcreatelist | `sharedToAll` | nein | `"true"` / `"false"` (default: `"true"`) | | `color` | nein | Hex-Farbwert z.B. `"#4784EC"` | | `emoji` | nein | Unicode-Emoji z.B. `"🛒"` | +| `scope` | nein | Kreis-metaId z.B. `"family/23447378"` (ohne scope → primärer Kreis) | + +**Scope-Verhalten:** +- Ohne `scope`: Liste wird im primären Kreis des Accounts erstellt +- Mit `scope=family/XXXX`: Liste wird im angegebenen Kreis erstellt +- Die `metaId` der neuen Liste kodiert den Kreis: `taskList/CIRCLENUM_LISTNUM` +- Parameter `familyId`, `circleId`, `family`, `id` → werden ignoriert, nur `scope` wirkt **Response:** ``` @@ -302,6 +309,7 @@ a00.r.r → vollständiges Listen-Objekt .sharedToAll → "true" / "false" .emoji → Unicode-Emoji (fehlt wenn nicht gesetzt) .color → Hex-Farbwert z.B. "#E53935" (fehlt wenn nicht gesetzt) + .familyId → Kreis-metaId des erstellten Liste .rights.canDelete → "true" (user-created lists) ``` @@ -312,9 +320,10 @@ POST https://api.familywall.com/api/taskdeletelist **Body-Parameter:** -| Parameter | Wert | -|---|---| -| `id` | Listen-metaId ⚠️ nicht `listId` oder `taskListId`! | +| Parameter | Pflicht | Wert | +|---|---|---| +| `id` | ja | Listen-metaId ⚠️ nicht `listId` oder `taskListId`! | +| `scope` | nein | Kreis-metaId (erforderlich für sekundäre Kreise) | **Response:** ``` @@ -324,20 +333,32 @@ a00.r.r → "true" (String) **Hinweis:** Löscht die Liste und alle enthaltenen Tasks unwiderruflich. System-Listen (`rights.canDelete` fehlt oder `null`) sind nicht löschbar. MCP-Server prüft dies vor dem Löschen via `taskgettasklists`. +Für Listen in sekundären Kreisen muss `scope=family/XXXX` mitgeschickt werden. **Verifiziert am:** 2026-04-16 via FW_DEBUG=1 ### `taskgettasklists` – Listen abrufen POST https://api.familywall.com/api/taskgettasklists -**Body-Parameter:** keine +**Body-Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `scope` | nein | Kreis-metaId z.B. `"family/23447378"` (ohne scope → primärer Kreis) | + +**Scope-Verhalten:** +- Ohne `scope`: Nur Listen des primären Kreises werden zurückgegeben +- Mit `scope=family/XXXX`: Nur Listen des angegebenen Kreises +- Es gibt keinen servereitigen Filter für mehrere Kreise gleichzeitig +- Andere Parameter (`familyId`, `circleId`, etc.) werden ignoriert **Response-Struktur:** ``` -a00.r.r[] → Liste aller Task-Listen +a00.r.r[] → Liste aller Task-Listen des Kreises .metaId → Listen-ID (z.B. "taskList/23431854_29740942") .name → Systembezeichnung oder Benutzer-Name .taskListType → SHOPPING_LIST oder TODOS + .familyId → Kreis-metaId (z.B. "family/23431854") .emoji → Unicode-Emoji oder "" (leerer String = kein Emoji) .color → Hex-Farbwert z.B. "#E53935" (fehlt wenn nicht gesetzt) .remainingTaskNumber → offene Tasks (String) @@ -348,6 +369,9 @@ a00.r.r[] → Liste aller Task-Listen .systemId → vorhanden nur bei Systemlisten (z.B. "-10", "-11") ``` +**metaId-Encoding:** `taskList/_` — die `family_num` kodiert den Kreis. +Beispiel: `taskList/23431854_29740942` gehört zu `family/23431854`. + **Hinweis emoji/color:** - `emoji`: Systemlisten liefern `""`, user-created Listen liefern den Emoji-String oder `""` wenn kein Emoji gesetzt. Normalisierung: `""` → `null` im MCP-Server. diff --git a/pyproject.toml b/pyproject.toml index 9c1f42a..1bd33d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.7.0" +version = "0.7.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 49e0fc1..a5f830a 100644 --- a/src/mcp_familywall/__init__.py +++ b/src/mcp_familywall/__init__.py @@ -1 +1 @@ -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index 8dd3fa4..548072c 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -141,6 +141,26 @@ def get_circles(): # --------------------------------------------------------------------------- +def _circle_id_from_list_id(list_id: str) -> str | None: + """Derive the circle metaId from a list metaId. + + The list metaId format is ``taskList/_``. + This function returns ``"family/"``. + + Args: + list_id: List metaId (e.g. ``"taskList/23431854_29759623"``). + + Returns: + Circle metaId (e.g. ``"family/23431854"``), or ``None`` when the + format cannot be parsed. + """ + bare = list_id.removeprefix("taskList/") + parts = bare.split("_", 1) + if len(parts) == 2 and parts[0].isdigit(): + return f"family/{parts[0]}" + return None + + def _famlistfamily() -> list[dict[str, Any]]: """Login, call famlistfamily, logout and return the raw circle list. @@ -252,17 +272,64 @@ def get_members(circle_id: str | None = None) -> str: @mcp.tool() -def get_lists(scope: str | None = None): - """Return task lists as JSON, optionally filtered by circle name (scope).""" +def get_lists(scope: str | None = None) -> str: + """Return task lists as JSON, optionally filtered to a specific circle. + + Each list object includes a ``circle_id`` field with the owning circle's + metaId (e.g. ``"family/23431854"``). + + Args: + scope: Optional circle filter. Accepts either: + + - A circle metaId (e.g. ``"family/23447378"``) — passed directly + to the API. + - A circle display name (e.g. ``"Test Kreis 2"``) — resolved to + the matching circle metaId via ``get_circles`` first. + + When ``None`` (default) the primary circle's lists are returned. + + Returns: + JSON list of list objects with keys id, name, type, open, total, + emoji, color, circle_id. + """ try: email, password = get_credentials() except RuntimeError as exc: return f"Error: {exc}" + # Build the scope param for taskgettasklists. + # When scope is provided as a circle name (not a metaId), we need to + # resolve it via famlistfamily first — done in the same session. + api_scope: str | None = None + if scope: + if scope.startswith("family/"): + api_scope = scope + else: + # Treat as circle name — look up the metaId. + try: + circles = _famlistfamily() + except RuntimeError as exc: + return f"Error: {exc}" + matched = next((c for c in circles if c.get("name") == scope), None) + if matched is None: + circle_names = [c.get("name") for c in circles] + return json.dumps( + { + "error": f"Circle not found: {scope!r}", + "available_circles": circle_names, + }, + ensure_ascii=False, + indent=2, + ) + api_scope = matched["metaId"] + try: with FamilyWallClient() as client: client.login(email, password) - data = client.call("taskgettasklists", {}) + params: dict[str, Any] = {} + if api_scope: + params["scope"] = api_scope + data = client.call("taskgettasklists", params) client.logout() except FamilyWallError as exc: return f"Error: {exc}" @@ -281,7 +348,6 @@ def get_lists(scope: str | None = None): pass if raw_lists is None: - # Response structure not yet verified — return raw JSON for inspection. return json.dumps( {"warning": "Unexpected taskgettasklists response structure", "raw": data}, ensure_ascii=False, @@ -290,7 +356,6 @@ def get_lists(scope: str | None = None): result = [] for item in raw_lists: - # TODO: apply scope filtering once the circle field is identified. # emoji: API returns "" when unset — normalise to None for a clean JSON null. # color: API omits the key entirely when unset — .get() returns None directly. raw_emoji: str = item.get("emoji", "") @@ -303,6 +368,7 @@ def get_lists(scope: str | None = None): "total": item.get("totalTaskNumber"), "emoji": raw_emoji if raw_emoji else None, "color": item.get("color") or None, + "circle_id": item.get("familyId"), } ) @@ -912,6 +978,7 @@ def create_list( shared_to_all: bool = True, color: str | None = None, emoji: str | None = None, + circle_id: str | None = None, ) -> str: """Create a new task list in Family Wall. @@ -924,9 +991,15 @@ def create_list( circle members. When ``False`` it is private to the creator. color: Optional background colour as a hex string (e.g. ``"#4784EC"``). emoji: Optional Unicode emoji to use as the list icon (e.g. ``"🛒"``). + circle_id: Optional circle metaId to create the list in + (e.g. ``"family/23447378"``). When ``None`` (default) the list + is created in the primary circle. Use ``get_circles`` to + retrieve available circle IDs. Returns: JSON with the new list object on success, or an error message. + Includes ``circle_id`` field showing which circle the list was + created in. """ if list_type not in ("SHOPPING_LIST", "TODOS"): return "Error: list_type must be 'SHOPPING_LIST' or 'TODOS'." @@ -942,6 +1015,9 @@ def create_list( params["color"] = color if emoji: params["emoji"] = emoji + if circle_id: + # The API uses the 'scope' parameter to specify the target circle. + params["scope"] = circle_id try: data = _authenticated_call("taskcreatelist", params) @@ -968,6 +1044,7 @@ def create_list( "shared_to_all": list_obj.get("sharedToAll") == "true", "emoji": raw_emoji if raw_emoji else None, "color": list_obj.get("color") or None, + "circle_id": list_obj.get("familyId") or circle_id, }, ensure_ascii=False, indent=2, @@ -1003,13 +1080,21 @@ def delete_list(list_id: str) -> str: except RuntimeError as exc: return f"Error: {exc}" + # Derive the owning circle from the list metaId so that secondary-circle + # lists can be queried and deleted with the correct scope parameter. + # Format: taskList/_ → family/ + circle_scope = _circle_id_from_list_id(list_id) + list_obj: dict[str, Any] | None = None try: with FamilyWallClient() as client: client.login(email, password) - # Fetch lists and verify the target can be deleted. - raw = client.call("taskgettasklists", {}) + # Fetch lists scoped to the correct circle and verify deletion rights. + get_params: dict[str, Any] = {} + if circle_scope: + get_params["scope"] = circle_scope + raw = client.call("taskgettasklists", get_params) try: raw_lists: list[dict[str, Any]] = raw["a00"]["r"]["r"] if not isinstance(raw_lists, list): @@ -1037,8 +1122,11 @@ def delete_list(list_id: str) -> str: ) # Verified — delete in the same session. - # taskdeletelist uses 'id' (same pattern as metadelete / taskcategorydelete). - client.call("taskdeletelist", {"id": list_id}) + # For secondary circles the 'scope' parameter is required. + del_params: dict[str, Any] = {"id": list_id} + if circle_scope: + del_params["scope"] = circle_scope + client.call("taskdeletelist", del_params) client.logout() except FamilyWallError as exc: return f"Error: Family Wall API error: {exc}"