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 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:10:05 +02:00
parent 2bc03e2165
commit abb557e96b
6 changed files with 144 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.0"
__version__ = "0.7.1"
+97 -9
View File
@@ -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/<family_num>_<list_num>``.
This function returns ``"family/<family_num>"``.
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_num>_<list_num> → family/<family_num>
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}"