From a76dc0fd51520f662bc98083b078ca24772bb0fb Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 16 Apr 2026 06:54:10 +0200 Subject: [PATCH] feat: category assignment in create_task / update_task (v0.4.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified via FW_DEBUG=1 + systematic param-name probing that the correct parameter is `taskCategoryId` with value = full metaId from get_categories (e.g. taskCategory/23431854_200). Numeric systemCategoryId alone causes API error; full metaId is accepted and stored. Changes: - create_task: add optional category_id parameter → sent as taskCategoryId - update_task: add optional category_id parameter → sent as taskCategoryId; guard now accepts category_id-only updates - get_tasks: expose category_id field in returned task objects - get_categories: update docstring (param name now known) - SPEC.md: document verified taskCategoryId param + clarify categories[] vs taskCategoryId field distinction - scripts/find_category_param.py: discovery script used to find param name Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 8 +- README.md | 8 +- SPEC.md | 36 ++++-- pyproject.toml | 2 +- scripts/find_category_param.py | 197 +++++++++++++++++++++++++++++++++ src/mcp_familywall/server.py | 37 +++++-- 6 files changed, 262 insertions(+), 26 deletions(-) create mode 100644 scripts/find_category_param.py diff --git a/CLAUDE.md b/CLAUDE.md index 51e3863..17c1fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,21 +23,21 @@ eingebunden. ## Aktueller Stand -### Implementierte Tools (v0.4.8) +### Implementierte Tools (v0.4.10) | Kategorie | Tools | |---|---| | Kreise | `get_circles`, `get_members` | | Listen | `get_lists` | -| Tasks (Lesen) | `get_tasks`, `get_categories` | +| Tasks (Lesen) | `get_tasks` (inkl. `category_id`), `get_categories` | | Wall | `get_activities`, `like_post` | -| Tasks (Schreiben) | `create_task`, `update_task`, `toggle_task`, `delete_task` | +| Tasks (Schreiben) | `create_task` (inkl. `category_id`), `update_task` (inkl. `category_id`), `toggle_task`, `delete_task` | ## Roadmap - v0.x: Erweiterter Lese- + Schreibzugriff ← aktuell -- Offen: Unlike (`like_post(like=False)`), Task-Kategorie-Zuweisung beim Erstellen/Aktualisieren +- Offen: Unlike (`like_post(like=False)`) ## Referenzprojekt diff --git a/README.md b/README.md index 712b9f2..12581e0 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your family's circles, lists, and tasks directly from Claude. -## Features (v0.4.9) +## Features (v0.4.10) ### Read - `get_circles` -- list all family circles - `get_members` -- list members of a circle (or all circles) - `get_lists` -- list all task lists (optionally filtered by circle) -- `get_tasks` -- list tasks in a specific list +- `get_tasks` -- list tasks in a specific list (includes `category_id` field) - `get_categories` -- list categories available for a list (locale-filtered, default: German) - `get_activities` -- list recent wall activities (author resolved to display name) ### Write (with confirmation prompt) -- `create_task` -- create a new task in a list -- `update_task` -- update the text/description of an existing task +- `create_task` -- create a new task in a list (supports `category_id` for shopping lists) +- `update_task` -- update text, description, and/or category of an existing task - `toggle_task` -- mark a task complete or reopen it - `delete_task` -- permanently delete a task - `like_post` -- like or unlike a wall post/activity diff --git a/SPEC.md b/SPEC.md index 4d16980..1a718aa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -158,19 +158,34 @@ a02.r.r.updatedCreated[] → tasksync (Tasks) .description → optionale Beschreibung .taskListId → Zugehörigkeit zur Liste .complete → "true" / "false" (String, nicht Boolean!) - .categories[] → zugewiesene Kategorien des Tasks - .system → "true" wenn System-Kategorie, "false" wenn custom - .name → Kategoriename (z.B. "SYS-CAT-TODOS", "Beverages") + .categories[] → Listen-Level-Systemkategorie (z.B. SYS-CAT-SHOPPINGLIST); + NICHT die spezifische Task-Kategorie — immer identisch + für alle Tasks einer Liste + .system → "true" (immer System-Kategorie) + .name → Listen-Systemkategorien (z.B. "SYS-CAT-SHOPPINGLIST", "SYS-CAT-TODOS") + .taskCategoryId → spezifische Task-Kategorie (verifiziert): metaId-Format + (z.B. "taskCategory/23431854_200"), null wenn nicht gesetzt .assignee / .assigneeIds → zugewiesene Mitglieder .reminder → Erinnerungsdatum (ISO 8601, optional) .recurrency → Wiederholungsregel (optional) .sortingIndex → Anzeigereihenfolge ``` -**Kategorie-Zuweisung bei taskcreate2 / taskupdate2:** Parameter-Name noch unbekannt. -Getestete Varianten (`taskCategoryId`, `categoryId`, `category`, `categoryMetaId`) werden -serverseitig ignoriert — die Task bekommt stets die Default-Systemkategorie der Liste. -Service Worker in der Web-App verhindert Inspektion des echten Requests. Noch zu verifizieren. +**Kategorie-Zuweisung bei taskcreate2 / taskupdate2 (verifiziert):** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `taskCategoryId` | nein | Kategorie-MetaId aus `get_categories` (z.B. `taskCategory/23431854_200`) | + +Hinweise: +- Wert muss das vollständige metaId-Format `taskCategory/_` sein. + Nur der numerische `systemCategoryId`-Teil (z.B. `200`) führt zu API-Fehler + `"cannot find task category id=200"`. +- Das `categories[]`-Feld in der Response zeigt immer `SYS-CAT-SHOPPINGLIST` + (Listen-Level-Systemkategorie, unabhängig vom gesetzten `taskCategoryId`). + Die tatsächliche Task-Kategorie ist im Feld `taskCategoryId` der Task gespeichert. +- Nur für Einkaufslisten (`taskListType=SHOPPING_LIST`) relevant; + TODO-Listen haben keine Kategorien. ## Systembezeichnungen für Listen-Namen @@ -244,6 +259,7 @@ Content-Type: application/x-www-form-urlencoded | `taskListId` | ja | Listen-ID aus `get_lists` (z.B. `taskList/123_456`) | | `text` | ja | Aufgabentitel | | `description` | nein | Optionale Beschreibung | +| `taskCategoryId` | nein | Kategorie-MetaId aus `get_categories` (z.B. `taskCategory/23431854_200`) | **Response-Struktur (verifiziert):** ``` @@ -263,8 +279,9 @@ Content-Type: application/x-www-form-urlencoded | Parameter | Pflicht | Wert | |---|---|---| | `metaId` | ja | Task-ID aus `get_tasks` | -| `text` | nein | Neuer Titel (mindestens `text` oder `description` erforderlich) | +| `text` | nein | Neuer Titel (mindestens eines der optionalen Felder erforderlich) | | `description` | nein | Neue Beschreibung | +| `taskCategoryId` | nein | Kategorie-MetaId aus `get_categories` (z.B. `taskCategory/23431854_200`) | Hinweis: `taskListId` ist **nicht** erforderlich (verifiziert – Update ohne `taskListId` funktioniert). @@ -408,4 +425,5 @@ AND `moodStarShortcut: false` AND `moodMap: {}`. - ~~`metadelete`: korrekter Parameter-Name + Response-Struktur~~ → **`id`**, Response `"true"` (verifiziert) - ~~`wallmood`: Parameter-Name `wallId`~~ → **`wall_message_id`** (verifiziert via API-Fehlermeldung) - ~~`wallmood`: `moodType`-Werte, Toggle vs. explizit, Response-Struktur~~ → verifiziert: idempotentes SET mit `"STAR"`, kein Toggle (siehe oben) -- `wallmood` Unlike: Mechanismus unbekannt — Service Worker verhindert Browser-Inspektion; alle getesteten Ansätze fehlgeschlagen (siehe oben) \ No newline at end of file +- `wallmood` Unlike: Mechanismus unbekannt — Service Worker verhindert Browser-Inspektion; alle getesteten Ansätze fehlgeschlagen (siehe oben) +- ~~`taskcreate2` / `taskupdate2`: Kategorie-Paramter-Name~~ → **`taskCategoryId`**, Wert = vollständige metaId (verifiziert) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0428bab..e04cacc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.4.9" +version = "0.4.10" 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/scripts/find_category_param.py b/scripts/find_category_param.py new file mode 100644 index 0000000..adaa255 --- /dev/null +++ b/scripts/find_category_param.py @@ -0,0 +1,197 @@ +"""Discover the category assignment parameter name for taskcreate2 / taskupdate2. + +Usage: + FW_DEBUG=1 python scripts/find_category_param.py + +The script: +1. Fetches the first available shopping-list category (German locale). +2. For each candidate parameter name, creates a test task with that parameter set, + checks whether the category appears in the response, then deletes the task. +3. Reports which parameter name (if any) caused the category to be applied. + +Prerequisites: credentials stored in OS keyring or FW_EMAIL / FW_PASSWORD set. +""" + +from __future__ import annotations + +import json +import os +import sys + +# Allow running from repo root without installing the package. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from mcp_familywall.auth import get_credentials +from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +# Shopping list to use for the test (adjust if needed). +TEST_LIST_ID = "taskList/23431854_29740941" + +# Category to assign — will be resolved from the first taskcategorysync entry +# for this list in German locale; override here if desired. +FORCED_CATEGORY_META_ID: str | None = None + +# Parameter-name candidates not yet tested (already ruled out are commented out): +CANDIDATES: list[dict[str, str | None]] = [ + # key → value format description + # Full metaId variants + {"key": "systemCategoryId", "fmt": "numeric"}, # just the number, e.g. "200" + {"key": "taskCategorySystemId", "fmt": "numeric"}, + {"key": "categories", "fmt": "meta_id"}, # full taskCategory/… metaId + {"key": "categoryIds", "fmt": "meta_id"}, + {"key": "taskCategoryName", "fmt": "name"}, # category name as string + {"key": "categoryName", "fmt": "name"}, + {"key": "taskCategoryId", "fmt": "meta_id"}, # already tried but try numeric too + # Numeric variants of already-tried names + {"key": "taskCategoryId", "fmt": "numeric"}, + {"key": "categoryId", "fmt": "numeric"}, + {"key": "category", "fmt": "numeric"}, + {"key": "categoryMetaId", "fmt": "numeric"}, +] + + +def _resolve_category( + client: FamilyWallClient, list_id: str +) -> tuple[str, str, str]: + """Return (meta_id, system_category_id_str, name) for the first German category.""" + data = client.call( + "accgetallfamily", + {"a01call": "taskcategorysync", "a02call": "tasksync"}, + ) + cats = data["a01"]["r"]["r"]["updatedCreated"] + + # Also resolve list type for filtering. + list_type: str | None = None + try: + list_data = client.call("taskgettasklists", {}) + for lst in list_data.get("a00", {}).get("r", {}).get("r", []) or []: + if lst.get("metaId") == list_id: + list_type = lst.get("taskListType") + break + except FamilyWallError: + pass + + for cat in cats: + if cat.get("locale") != "de": + continue + if list_type and cat.get("taskListType") != list_type: + continue + meta_id: str = cat["metaId"] + sys_id: str = str(cat.get("systemCategoryId", "")) + name: str = cat.get("name", "") + print(f" Using category: metaId={meta_id!r}, systemCategoryId={sys_id!r}, name={name!r}") + return meta_id, sys_id, name + + raise RuntimeError("No German category found for list; adjust TEST_LIST_ID.") + + +def _get_task_categories(task_obj: dict) -> list[str]: + """Extract category names from a task API object.""" + cats = task_obj.get("categories") or [] + return [c.get("name", "") for c in cats if isinstance(c, dict)] + + +def _delete_task(client: FamilyWallClient, task_id: str) -> None: + try: + client.call("metadelete", {"id": task_id}) + except FamilyWallError as exc: + print(f" [warn] Could not delete task {task_id}: {exc}") + + +def main() -> None: + email, password = get_credentials() + + with FamilyWallClient() as client: + client.login(email, password) + + print(f"\nResolving category for list {TEST_LIST_ID!r} …") + if FORCED_CATEGORY_META_ID: + # Can't resolve name/numeric from meta_id easily; just use the meta_id. + cat_meta = FORCED_CATEGORY_META_ID + cat_numeric = cat_meta.split("_")[-1] if "_" in cat_meta else cat_meta + cat_name = cat_meta + else: + cat_meta, cat_numeric, cat_name = _resolve_category(client, TEST_LIST_ID) + + value_map = { + "meta_id": cat_meta, + "numeric": cat_numeric, + "name": cat_name, + } + + print(f"\nTesting {len(CANDIDATES)} candidate parameter names …\n") + results: list[tuple[str, str, bool]] = [] + + seen: set[tuple[str, str]] = set() # avoid duplicate tests + + for cand in CANDIDATES: + key: str = cand["key"] # type: ignore[assignment] + fmt: str = cand["fmt"] # type: ignore[assignment] + value: str = value_map[fmt] + pair = (key, value) + if pair in seen: + continue + seen.add(pair) + + params: dict = { + "taskListId": TEST_LIST_ID, + "text": f"[TEST] cat-param-probe {key}={fmt}", + key: value, + } + + print(f" Testing {key!r} = {value!r} …", end=" ", flush=True) + try: + data = client.call("taskcreate2", params) + except FamilyWallError as exc: + print(f"API ERROR: {exc}") + results.append((key, value, False)) + continue + + task_obj = data.get("a00", {}).get("r", {}).get("r", {}) + task_id: str = task_obj.get("metaId", "") + applied_cats = _get_task_categories(task_obj) + + # A non-default category was applied when the name matches our target. + success = any(cat_name in c or c == cat_name for c in applied_cats) + + if success: + print(f"SUCCESS -> categories={applied_cats}") + else: + print(f"no effect -> categories={applied_cats}") + + results.append((key, value, success)) + + if task_id: + _delete_task(client, task_id) + + client.logout() + + # ----------------------------------------------------------------------- + # Summary + # ----------------------------------------------------------------------- + print("\n" + "=" * 60) + print("RESULTS") + print("=" * 60) + hits = [(k, v) for k, v, ok in results if ok] + misses = [(k, v) for k, v, ok in results if not ok] + + if hits: + print(f"\nWORKING parameter(s) found ({len(hits)}):") + for k, v in hits: + print(f" {k!r} = {v!r}") + else: + print("\nNo working parameter found.") + + print(f"\nDid NOT work ({len(misses)}):") + for k, v in misses: + print(f" {k!r} = {v!r}") + + print() + + +if __name__ == "__main__": + main() diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index e6ae6f1..1b6b82e 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -325,6 +325,7 @@ def get_tasks(list_id: str, only_open: bool = True): "text": task.get("text"), "description": task.get("description"), "completed": completed, + "category_id": task.get("taskCategoryId"), } ) @@ -344,8 +345,8 @@ def get_categories(list_id: str, locale: str = "de") -> str: lists return an empty list. Categories are filtered by locale so only the language-appropriate names are returned (default: German). - Note: the parameter name for assigning a category when creating or - updating a task is not yet verified via FW_DEBUG=1. See SPEC.md. + Use the returned ``id`` values as the ``category_id`` parameter in + ``create_task`` and ``update_task``. Args: list_id: List ID from get_lists (e.g. ``taskList/23431854_29740942``). @@ -517,7 +518,12 @@ def _authenticated_call(endpoint: str, params: dict[str, Any]) -> dict[str, Any] @mcp.tool() -def create_task(list_id: str, text: str, description: str | None = None) -> str: +def create_task( + list_id: str, + text: str, + description: str | None = None, + category_id: str | None = None, +) -> str: """Create a new task in the given list. IMPORTANT: Ask the user for confirmation before calling this tool. @@ -526,6 +532,9 @@ def create_task(list_id: str, text: str, description: str | None = None) -> str: list_id: Target list ID from get_lists (e.g. ``taskList/123_456``). text: Task title / main text. description: Optional longer description. + category_id: Optional category metaId from get_categories + (e.g. ``taskCategory/23431854_200``). Only meaningful for + shopping lists; ignored for TODO lists. Returns: JSON with the new task's metaId on success, or an error message. @@ -533,6 +542,8 @@ def create_task(list_id: str, text: str, description: str | None = None) -> str: params: dict[str, Any] = {"taskListId": list_id, "text": text} if description: params["description"] = description + if category_id: + params["taskCategoryId"] = category_id try: data = _authenticated_call("taskcreate2", params) @@ -559,28 +570,38 @@ def create_task(list_id: str, text: str, description: str | None = None) -> str: @mcp.tool() -def update_task(task_id: str, text: str | None = None, description: str | None = None) -> str: - """Update the text and/or description of an existing task. +def update_task( + task_id: str, + text: str | None = None, + description: str | None = None, + category_id: str | None = None, +) -> str: + """Update the text, description, and/or category of an existing task. IMPORTANT: Ask the user for confirmation before calling this tool. - At least one of *text* or *description* must be provided. + At least one of *text*, *description*, or *category_id* must be provided. Args: task_id: Task metaId from get_tasks. text: New title text (omit to leave unchanged). description: New description (omit to leave unchanged). + category_id: New category metaId from get_categories + (e.g. ``taskCategory/23431854_200``). Only meaningful for + shopping lists; ignored for TODO lists. Returns: JSON success indicator or an error message. """ - if text is None and description is None: - return "Error: At least one of 'text' or 'description' must be provided." + if text is None and description is None and category_id is None: + return "Error: At least one of 'text', 'description', or 'category_id' must be provided." params: dict[str, Any] = {"metaId": task_id} if text is not None: params["text"] = text if description is not None: params["description"] = description + if category_id is not None: + params["taskCategoryId"] = category_id try: _authenticated_call("taskupdate2", params)