feat: category assignment in create_task / update_task (v0.4.10)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:54:10 +02:00
parent 9bc6a54783
commit a76dc0fd51
6 changed files with 262 additions and 26 deletions
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+27 -9
View File
@@ -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/<familyId>_<systemCategoryId>` 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)
- `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)
+1 -1
View File
@@ -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"
+197
View File
@@ -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()
+29 -8
View File
@@ -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)