7abe58dee2
Documents the preferred format for quantities in shopping list tasks: item name first, quantity in parentheses at the end. Examples: "Äpfel (5x)", "Hackfleisch (500g)", "Joghurt (Erdbeere, 2x)". No functional code changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1130 lines
39 KiB
Python
1130 lines
39 KiB
Python
"""MCP server for Family Wall — tools for circles, lists, tasks (read + write)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from mcp_familywall.auth import get_credentials
|
|
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
|
|
from mcp_familywall.modules.lists import translate_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
mcp = FastMCP("familywall")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _accgetallfamily() -> dict[str, Any]:
|
|
"""Login, call accgetallfamily, logout and return the response body.
|
|
|
|
Raises:
|
|
RuntimeError: On credential or API errors.
|
|
"""
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
raise RuntimeError(str(exc)) from exc
|
|
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
data = client.call(
|
|
"accgetallfamily",
|
|
{"a01call": "taskcategorysync", "a02call": "tasksync"},
|
|
)
|
|
client.logout()
|
|
return data
|
|
except FamilyWallError as exc:
|
|
raise RuntimeError(f"Family Wall API error: {exc}") from exc
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Connection error: {exc}") from exc
|
|
|
|
|
|
def _extract_lists(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Extract task lists from an accgetallfamily response.
|
|
|
|
List IDs are derived from the sortingIndexByTaskList keys present in each
|
|
taskcategorysync entry (a01.r.r.updatedCreated[]). Each key is a unique
|
|
list ID of the form ``taskList/<id>``. Names and counters are not yet
|
|
available from this path and are left as None.
|
|
|
|
Args:
|
|
data: Raw response body from accgetallfamily.
|
|
|
|
Returns:
|
|
Deduplicated list of dicts with keys id, name, type, open, total.
|
|
"""
|
|
try:
|
|
categories = data["a01"]["r"]["r"]["updatedCreated"]
|
|
if not isinstance(categories, list):
|
|
return []
|
|
except (KeyError, TypeError):
|
|
return []
|
|
|
|
seen: set[str] = set()
|
|
result: list[dict[str, Any]] = []
|
|
for cat in categories:
|
|
sorting = cat.get("sortingIndexByTaskList")
|
|
if not isinstance(sorting, dict):
|
|
continue
|
|
for list_id in sorting:
|
|
if list_id in seen:
|
|
continue
|
|
seen.add(list_id)
|
|
result.append(
|
|
{
|
|
"id": list_id,
|
|
"name": list_id, # real name unknown — TODO once field identified
|
|
"type": None,
|
|
"open": None,
|
|
"total": None,
|
|
}
|
|
)
|
|
|
|
logger.debug("Extracted %d unique list IDs from sortingIndexByTaskList", len(result))
|
|
return result
|
|
|
|
|
|
def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Extract the list of tasks from an accgetallfamily response.
|
|
|
|
Tasks live under a02.r.r.updatedCreated[].
|
|
|
|
Args:
|
|
data: Raw response body from accgetallfamily.
|
|
|
|
Returns:
|
|
List of raw task dicts (may be empty).
|
|
"""
|
|
try:
|
|
items = data["a02"]["r"]["r"]["updatedCreated"]
|
|
if isinstance(items, list):
|
|
return items # type: ignore[return-value]
|
|
except (KeyError, TypeError):
|
|
pass
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_circles
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_circles():
|
|
"""Return all Family Wall circles as JSON list of {id, name}."""
|
|
try:
|
|
raw_circles = _famlistfamily()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
result = [{"id": c.get("metaId"), "name": c.get("name")} for c in raw_circles]
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: famlistfamily raw circles
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _famlistfamily() -> list[dict[str, Any]]:
|
|
"""Login, call famlistfamily, logout and return the raw circle list.
|
|
|
|
Raises:
|
|
RuntimeError: On credential or API errors.
|
|
"""
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
raise RuntimeError(str(exc)) from exc
|
|
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
data = client.call("famlistfamily")
|
|
client.logout()
|
|
except FamilyWallError as exc:
|
|
raise RuntimeError(f"Family Wall API error: {exc}") from exc
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Connection error: {exc}") from exc
|
|
|
|
try:
|
|
raw = data["a00"]["r"]["r"]
|
|
if not isinstance(raw, list):
|
|
raise TypeError("a00.r.r is not a list")
|
|
return raw
|
|
except (KeyError, TypeError) as exc:
|
|
raise RuntimeError(f"Unexpected famlistfamily response structure: {exc}") from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_members
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_members(circle_id: str | None = None) -> str:
|
|
"""Return Family Wall circle members as JSON, optionally filtered by circle.
|
|
|
|
Args:
|
|
circle_id: Optional circle ID from get_circles (e.g. ``family/23431854``).
|
|
When omitted all members across all circles are returned.
|
|
|
|
Returns:
|
|
JSON list of member objects with keys id, name, email, role, right,
|
|
color, avatar, circle_id, circle_name.
|
|
"""
|
|
try:
|
|
raw_circles = _famlistfamily()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
result: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
|
|
for circle in raw_circles:
|
|
c_id: str = circle.get("metaId", "")
|
|
c_name: str = circle.get("name", "")
|
|
|
|
if circle_id is not None and c_id != circle_id:
|
|
continue
|
|
|
|
for member in circle.get("members") or []:
|
|
account_id: str = member.get("accountId", "")
|
|
|
|
# Deduplicate members that appear in multiple circles when no
|
|
# circle_id filter is active (same account can be in several circles).
|
|
dedup_key = f"{c_id}:{account_id}"
|
|
if dedup_key in seen_ids:
|
|
continue
|
|
seen_ids.add(dedup_key)
|
|
|
|
# Extract avatar URL from the first media entry (may be a generated
|
|
# default avatar when pictureDefault is "true").
|
|
medias = member.get("medias") or []
|
|
avatar: str | None = medias[0].get("pictureUrl") if medias else None
|
|
|
|
# Prefer firstName as display name; fall back to the name field
|
|
# (which Family Wall sets to the e-mail address by default).
|
|
display_name: str = member.get("firstName") or member.get("name", "")
|
|
|
|
# Extract e-mail from identifiers list.
|
|
email_value: str | None = None
|
|
for ident in member.get("identifiers") or []:
|
|
if ident.get("type") == "Email":
|
|
email_value = ident.get("value")
|
|
break
|
|
|
|
result.append(
|
|
{
|
|
"id": account_id,
|
|
"name": display_name,
|
|
"email": email_value,
|
|
"role": member.get("role"),
|
|
"right": member.get("right"),
|
|
"color": member.get("color"),
|
|
"avatar": avatar,
|
|
"circle_id": c_id,
|
|
"circle_name": c_name,
|
|
}
|
|
)
|
|
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_lists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_lists(scope: str | None = None):
|
|
"""Return task lists as JSON, optionally filtered by circle name (scope)."""
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
data = client.call("taskgettasklists", {})
|
|
client.logout()
|
|
except FamilyWallError as exc:
|
|
return f"Error: {exc}"
|
|
except Exception as exc:
|
|
return f"Connection error: {exc}"
|
|
|
|
# Try known response patterns; fall back to raw JSON for verification.
|
|
raw_lists: list[dict[str, Any]] | None = None
|
|
try:
|
|
candidate = data["a00"]["r"]["r"]
|
|
if isinstance(candidate, list):
|
|
raw_lists = candidate
|
|
elif isinstance(candidate, dict) and isinstance(candidate.get("updatedCreated"), list):
|
|
raw_lists = candidate["updatedCreated"]
|
|
except (KeyError, TypeError):
|
|
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,
|
|
indent=2,
|
|
)
|
|
|
|
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", "")
|
|
result.append(
|
|
{
|
|
"id": item.get("metaId"),
|
|
"name": translate_name(item.get("name", "")),
|
|
"type": item.get("taskListType"),
|
|
"open": item.get("remainingTaskNumber"),
|
|
"total": item.get("totalTaskNumber"),
|
|
"emoji": raw_emoji if raw_emoji else None,
|
|
"color": item.get("color") or None,
|
|
}
|
|
)
|
|
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_tasks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_tasks(list_id: str, only_open: bool = True):
|
|
"""Return tasks for a list as JSON. list_id from get_lists. only_open=True filters completed."""
|
|
try:
|
|
data = _accgetallfamily()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
raw_tasks = _extract_tasks(data)
|
|
|
|
result = []
|
|
for task in raw_tasks:
|
|
if task.get("taskListId") != list_id:
|
|
continue
|
|
completed = str(task.get("complete", "false")).lower() == "true"
|
|
if only_open and completed:
|
|
continue
|
|
result.append(
|
|
{
|
|
"id": task.get("metaId"),
|
|
"text": task.get("text"),
|
|
"description": task.get("description"),
|
|
"completed": completed,
|
|
"category_id": task.get("taskCategoryId"),
|
|
"due_date": task.get("dueDate"),
|
|
"assignee_ids": task.get("assigneeIds") or [],
|
|
}
|
|
)
|
|
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_categories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_categories(list_id: str, locale: str = "de") -> str:
|
|
"""Return the task categories available for a list as JSON.
|
|
|
|
Only shopping lists (taskListType=SHOPPING_LIST) have categories. TODO
|
|
lists return an empty list. Categories are filtered by locale so only
|
|
the language-appropriate names are returned (default: German).
|
|
|
|
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``).
|
|
locale: BCP-47 language code for category names (default ``"de"``).
|
|
Supported values seen in API: de, en, fr, es, it, nl, pt, sv,
|
|
ru, ko, ja.
|
|
|
|
Returns:
|
|
JSON list of {id, name, emoji} objects ordered by sortingIndex,
|
|
or an empty list when the list type has no categories.
|
|
"""
|
|
# Resolve the list's taskListType from taskgettasklists so we can match
|
|
# only the categories that belong to the same list type. Non-fatal: if
|
|
# the lookup fails we skip the type filter and return all locale matches.
|
|
list_type: str | None = None
|
|
try:
|
|
list_data = _authenticated_call("taskgettasklists", {})
|
|
raw_lists = list_data.get("a00", {}).get("r", {}).get("r", []) or []
|
|
for lst in raw_lists:
|
|
if lst.get("metaId") == list_id:
|
|
list_type = lst.get("taskListType")
|
|
break
|
|
except RuntimeError:
|
|
pass
|
|
|
|
try:
|
|
data = _accgetallfamily()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
try:
|
|
raw_cats = data["a01"]["r"]["r"]["updatedCreated"]
|
|
if not isinstance(raw_cats, list):
|
|
raise TypeError("a01.r.r.updatedCreated is not a list")
|
|
except (KeyError, TypeError):
|
|
return json.dumps(
|
|
{"warning": "Unexpected taskcategorysync response structure", "raw": data.get("a01")},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
# Filter by locale (exact match) and taskListType (when known).
|
|
# sortingIndexByTaskList is NOT used for filtering: all categories are
|
|
# assigned to all lists regardless of type, making it an unreliable signal.
|
|
#
|
|
# Custom categories (rights.canDelete=true) bypass both filters: they have
|
|
# no locale or taskListType set by the API and must always be returned.
|
|
matched: list[tuple[int, dict[str, Any]]] = []
|
|
for cat in raw_cats:
|
|
is_custom = cat.get("rights", {}).get("canDelete") == "true"
|
|
if not is_custom:
|
|
if cat.get("locale") != locale:
|
|
continue
|
|
if list_type is not None and cat.get("taskListType") != list_type:
|
|
continue
|
|
sort_val = int(cat.get("initialSortingIndex") or 0)
|
|
matched.append((sort_val, cat))
|
|
|
|
matched.sort(key=lambda t: t[0])
|
|
result = [
|
|
{
|
|
"id": cat.get("metaId"),
|
|
"name": cat.get("name"),
|
|
"emoji": cat.get("emoji"),
|
|
# custom=True means the category was created by the family and can be
|
|
# deleted with delete_category. System categories have no canDelete right.
|
|
"custom": cat.get("rights", {}).get("canDelete") == "true",
|
|
}
|
|
for _, cat in matched
|
|
]
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: create_category
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def create_category(list_id: str, name: str, icon: str | None = None) -> str:
|
|
"""Create a new custom category for a shopping list.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
Custom categories appear alongside system categories when assigning
|
|
categories to tasks via ``create_task`` or ``update_task``. They can
|
|
later be removed with ``delete_category``.
|
|
|
|
Note: although ``list_id`` is accepted for context, the Family Wall API
|
|
assigns new categories to all lists in the family — there is no
|
|
per-list restriction.
|
|
|
|
Args:
|
|
list_id: Target list ID from get_lists (e.g. ``taskList/123_456``).
|
|
Only SHOPPING_LIST lists have categories; the parameter is
|
|
accepted for user context but does not restrict category scope.
|
|
name: Display name for the new category (e.g. ``"Bio-Produkte"``).
|
|
icon: Optional icon for the category. Pass a Unicode emoji character
|
|
(e.g. ``"🌿"``) or any short string identifier. When omitted the
|
|
category has no icon.
|
|
|
|
Returns:
|
|
JSON with the new category's ``id`` and ``name`` on success, or an
|
|
error message.
|
|
"""
|
|
params: dict[str, Any] = {"name": name}
|
|
if icon:
|
|
params["emoji"] = icon
|
|
|
|
try:
|
|
data = _authenticated_call("taskcategoryput", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
try:
|
|
cat_obj = data["a00"]["r"]["r"]
|
|
meta_id: str = cat_obj["metaId"]
|
|
except (KeyError, TypeError):
|
|
return json.dumps(
|
|
{"warning": "Unexpected taskcategoryput response structure", "raw": data},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
return json.dumps(
|
|
{"created": True, "id": meta_id, "name": cat_obj.get("name", name)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: delete_category
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def delete_category(category_id: str) -> str:
|
|
"""Permanently delete a custom category.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
Only custom (user-created) categories can be deleted. System categories
|
|
supplied by Family Wall (identified by ``custom=false`` in
|
|
``get_categories`` output) are protected and this tool will refuse to
|
|
delete them.
|
|
|
|
Args:
|
|
category_id: Category metaId from get_categories
|
|
(e.g. ``taskCategory/23431854_4956637``). Must be a custom
|
|
category (``custom=true`` in get_categories output).
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
# Safety check + delete in a SINGLE session to minimise API round-trips.
|
|
# Previously two separate sessions were used (accgetallfamily + taskcategorydelete),
|
|
# causing 6 HTTP calls. One session = 4 HTTP calls (login, check, delete, logout).
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
cat_obj: dict[str, Any] | None = None
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
|
|
# Fetch categories and verify the target is custom (canDelete=true).
|
|
data = client.call(
|
|
"accgetallfamily",
|
|
{"a01call": "taskcategorysync", "a02call": "tasksync"},
|
|
)
|
|
try:
|
|
raw_cats: list[dict[str, Any]] = data["a01"]["r"]["r"]["updatedCreated"]
|
|
except (KeyError, TypeError):
|
|
raw_cats = []
|
|
|
|
cat_obj = next(
|
|
(c for c in raw_cats if c.get("metaId") == category_id), None
|
|
)
|
|
if cat_obj is None:
|
|
client.logout()
|
|
return f"Error: Category '{category_id}' not found."
|
|
|
|
can_delete: str | None = cat_obj.get("rights", {}).get("canDelete")
|
|
if can_delete != "true":
|
|
client.logout()
|
|
return json.dumps(
|
|
{
|
|
"error": "System categories cannot be deleted.",
|
|
"id": category_id,
|
|
"name": cat_obj.get("name"),
|
|
"hint": "Only custom categories (custom=true in get_categories) can be deleted.",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
# Verified — delete in the same session.
|
|
client.call("taskcategorydelete", {"id": category_id})
|
|
client.logout()
|
|
except FamilyWallError as exc:
|
|
return f"Error: Family Wall API error: {exc}"
|
|
except Exception as exc:
|
|
return f"Error: Connection error: {exc}"
|
|
|
|
return json.dumps(
|
|
{"deleted": True, "id": category_id, "name": cat_obj.get("name")},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: get_activities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def get_activities(limit: int = 20):
|
|
"""Return recent Family Wall wall activities as JSON. limit controls max number of results."""
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
# Load member data to resolve author IDs to display names.
|
|
# Non-fatal: fall back to raw account IDs when member lookup fails.
|
|
author_map: dict[str, str] = {}
|
|
try:
|
|
for circle in _famlistfamily():
|
|
for member in circle.get("members") or []:
|
|
acc_id: str = member.get("accountId", "")
|
|
display = member.get("firstName") or member.get("name") or acc_id
|
|
if acc_id:
|
|
author_map[acc_id] = display
|
|
except RuntimeError:
|
|
pass
|
|
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
data = client.call("wallget", {"nb": str(limit)})
|
|
client.logout()
|
|
except FamilyWallError as exc:
|
|
return f"Error: {exc}"
|
|
except Exception as exc:
|
|
return f"Connection error: {exc}"
|
|
|
|
# Try known response patterns; fall back to raw JSON for verification.
|
|
raw_activities: list[dict[str, Any]] | None = None
|
|
try:
|
|
candidate = data["a00"]["r"]["r"]
|
|
if isinstance(candidate, list):
|
|
raw_activities = candidate
|
|
elif isinstance(candidate, dict) and isinstance(candidate.get("updatedCreated"), list):
|
|
raw_activities = candidate["updatedCreated"]
|
|
except (KeyError, TypeError):
|
|
pass
|
|
|
|
if raw_activities is None:
|
|
# Response structure not yet verified — return raw JSON for inspection.
|
|
return json.dumps(
|
|
{"warning": "Unexpected wallget response structure", "raw": data},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
result = []
|
|
for item in raw_activities:
|
|
raw_author: str = item.get("accountId", "")
|
|
result.append(
|
|
{
|
|
"id": item.get("metaId"),
|
|
"type": item.get("refType"),
|
|
"text": item.get("text"),
|
|
"date": item.get("creationDate"),
|
|
"author": author_map.get(raw_author, raw_author),
|
|
"author_id": raw_author,
|
|
}
|
|
)
|
|
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: authenticated single call
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _authenticated_call(endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Login, call *endpoint* with *params*, logout, and return the response body.
|
|
|
|
Args:
|
|
endpoint: Family Wall API endpoint name.
|
|
params: Form parameters to send.
|
|
|
|
Raises:
|
|
RuntimeError: On credential or API errors.
|
|
"""
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
raise RuntimeError(str(exc)) from exc
|
|
|
|
try:
|
|
with FamilyWallClient() as client:
|
|
client.login(email, password)
|
|
data = client.call(endpoint, params)
|
|
client.logout()
|
|
return data
|
|
except FamilyWallError as exc:
|
|
raise RuntimeError(f"Family Wall API error: {exc}") from exc
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Connection error: {exc}") from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: create_task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def create_task(
|
|
list_id: str,
|
|
text: str,
|
|
description: str | None = None,
|
|
category_id: str | None = None,
|
|
due_date: str | None = None,
|
|
assignee_ids: list[str] | None = None,
|
|
) -> str:
|
|
"""Create a new task in the given list.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
Args:
|
|
list_id: Target list ID from get_lists (e.g. ``taskList/123_456``).
|
|
text: Task title / main text.
|
|
For quantities use the format "Äpfel (5x)" — item name first,
|
|
quantity in parentheses at the end. Examples:
|
|
"Äpfel (5x)", "Hackfleisch (500g)", "Joghurt (Erdbeere, 2x)".
|
|
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.
|
|
due_date: Optional due date in ISO 8601 format (e.g. ``"2026-04-30T18:00:00"``).
|
|
assignee_ids: Optional list of member IDs from get_members to assign the task
|
|
(e.g. ``["23431898"]``). Empty list assigns to nobody.
|
|
|
|
Returns:
|
|
JSON with the new task's metaId on success, or an error message.
|
|
"""
|
|
params: dict[str, Any] = {"taskListId": list_id, "text": text}
|
|
if description:
|
|
params["description"] = description
|
|
if category_id:
|
|
params["taskCategoryId"] = category_id
|
|
if due_date is not None:
|
|
params["dueDate"] = due_date
|
|
if assignee_ids is not None:
|
|
params["assignee"] = assignee_ids if assignee_ids else ""
|
|
|
|
try:
|
|
data = _authenticated_call("taskcreate2", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
# Try to extract the new task's metaId from the response.
|
|
try:
|
|
meta_id = data["a00"]["r"]["r"]["metaId"]
|
|
except (KeyError, TypeError):
|
|
# Return raw response so the caller can inspect the actual structure.
|
|
return json.dumps(
|
|
{"warning": "Unexpected taskcreate2 response structure", "raw": data},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
return json.dumps({"created": True, "id": meta_id}, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: update_task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def update_task(
|
|
task_id: str,
|
|
text: str | None = None,
|
|
description: str | None = None,
|
|
category_id: str | None = None,
|
|
due_date: str | None = None,
|
|
clear_due_date: bool = False,
|
|
assignee_ids: list[str] | None = None,
|
|
list_id: str | None = None,
|
|
) -> str:
|
|
"""Update an existing task's fields.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
At least one parameter besides *task_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.
|
|
due_date: New due date in ISO 8601 format (e.g. ``"2026-04-30T18:00:00"``).
|
|
Omit to leave unchanged. Cannot be used together with *clear_due_date*.
|
|
clear_due_date: Set to ``True`` to remove the due date from the task.
|
|
Cannot be used together with *due_date*.
|
|
assignee_ids: New list of member IDs from get_members (e.g. ``["23431898"]``).
|
|
Pass an empty list to remove all assignees. Omit to leave unchanged.
|
|
list_id: Move the task to a different list by providing the target list ID
|
|
from get_lists (e.g. ``"taskList/23431854_29740942"``). Omit to keep in
|
|
current list.
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
if clear_due_date and due_date is not None:
|
|
return "Error: 'clear_due_date' and 'due_date' cannot be used together."
|
|
|
|
if (
|
|
text is None
|
|
and description is None
|
|
and category_id is None
|
|
and due_date is None
|
|
and not clear_due_date
|
|
and assignee_ids is None
|
|
and list_id is None
|
|
):
|
|
return "Error: At least one of 'text', 'description', 'category_id', 'due_date', 'clear_due_date', 'assignee_ids', or 'list_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
|
|
if clear_due_date:
|
|
# The FiZ framework uses "$empty" as a sentinel to clear optional date fields.
|
|
params["dueDate"] = "$empty"
|
|
elif due_date is not None:
|
|
params["dueDate"] = due_date
|
|
if assignee_ids is not None:
|
|
params["assignee"] = assignee_ids if assignee_ids else ""
|
|
if list_id is not None:
|
|
params["taskListId"] = list_id
|
|
|
|
try:
|
|
_authenticated_call("taskupdate2", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
# A response without 'ex'/'un' keys is treated as success by fw_client.
|
|
return json.dumps({"updated": True, "id": task_id}, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: toggle_task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def toggle_task(task_id: str, complete: bool) -> str:
|
|
"""Mark a task as complete or incomplete.
|
|
|
|
Args:
|
|
task_id: Task metaId from get_tasks.
|
|
complete: ``True`` to mark done, ``False`` to reopen.
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
params: dict[str, Any] = {
|
|
"taskId": task_id, # verified: taskmark uses 'taskId', not 'metaId'
|
|
"complete": "true" if complete else "false",
|
|
}
|
|
|
|
try:
|
|
_authenticated_call("taskmark", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
return json.dumps(
|
|
{"toggled": True, "id": task_id, "complete": complete},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: delete_task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def delete_task(task_id: str) -> str:
|
|
"""Permanently delete a task. This action cannot be undone.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
Args:
|
|
task_id: Task metaId from get_tasks.
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
try:
|
|
_authenticated_call(
|
|
"metadelete", {"id": task_id}
|
|
) # verified: metadelete uses 'id', not 'metaId'
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
return json.dumps({"deleted": True, "id": task_id}, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: create_list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def create_list(
|
|
name: str,
|
|
list_type: str,
|
|
shared_to_all: bool = True,
|
|
color: str | None = None,
|
|
emoji: str | None = None,
|
|
) -> str:
|
|
"""Create a new task list in Family Wall.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
Args:
|
|
name: Display name for the new list (max 200 characters).
|
|
list_type: List type — either ``"SHOPPING_LIST"`` or ``"TODOS"``.
|
|
shared_to_all: When ``True`` (default) the list is shared with all
|
|
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. ``"🛒"``).
|
|
|
|
Returns:
|
|
JSON with the new list object on success, or an error message.
|
|
"""
|
|
if list_type not in ("SHOPPING_LIST", "TODOS"):
|
|
return "Error: list_type must be 'SHOPPING_LIST' or 'TODOS'."
|
|
if len(name) > 200:
|
|
return "Error: name must not exceed 200 characters."
|
|
|
|
params: dict[str, Any] = {
|
|
"name": name,
|
|
"taskListType": list_type,
|
|
"sharedToAll": "true" if shared_to_all else "false",
|
|
}
|
|
if color:
|
|
params["color"] = color
|
|
if emoji:
|
|
params["emoji"] = emoji
|
|
|
|
try:
|
|
data = _authenticated_call("taskcreatelist", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
try:
|
|
list_obj = data["a00"]["r"]["r"]
|
|
meta_id: str = list_obj["metaId"]
|
|
except (KeyError, TypeError):
|
|
return json.dumps(
|
|
{"warning": "Unexpected taskcreatelist response structure", "raw": data},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
raw_emoji: str = list_obj.get("emoji", "")
|
|
return json.dumps(
|
|
{
|
|
"created": True,
|
|
"id": meta_id,
|
|
"name": list_obj.get("name", name),
|
|
"type": list_obj.get("taskListType"),
|
|
"shared_to_all": list_obj.get("sharedToAll") == "true",
|
|
"emoji": raw_emoji if raw_emoji else None,
|
|
"color": list_obj.get("color") or None,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: delete_list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def delete_list(list_id: str) -> str:
|
|
"""Permanently delete a task list and all its tasks.
|
|
|
|
IMPORTANT: Ask the user for confirmation before calling this tool.
|
|
|
|
This action cannot be undone. All tasks inside the list are also deleted.
|
|
Only lists with ``rights.canDelete="true"`` (user-created lists) can be
|
|
deleted. System lists are protected and this tool will refuse to delete them.
|
|
|
|
Args:
|
|
list_id: List metaId from get_lists
|
|
(e.g. ``"taskList/23431854_29759623"``). Must be a custom list
|
|
(``rights.canDelete="true"``).
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
# Verify + delete in a single session to minimise round-trips.
|
|
try:
|
|
email, password = get_credentials()
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
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", {})
|
|
try:
|
|
raw_lists: list[dict[str, Any]] = raw["a00"]["r"]["r"]
|
|
if not isinstance(raw_lists, list):
|
|
raw_lists = []
|
|
except (KeyError, TypeError):
|
|
raw_lists = []
|
|
|
|
list_obj = next(
|
|
(lst for lst in raw_lists if lst.get("metaId") == list_id), None
|
|
)
|
|
if list_obj is None:
|
|
client.logout()
|
|
return f"Error: List '{list_id}' not found."
|
|
|
|
can_delete: str | None = (list_obj.get("rights") or {}).get("canDelete")
|
|
if can_delete != "true":
|
|
client.logout()
|
|
return json.dumps(
|
|
{
|
|
"error": "System lists cannot be deleted.",
|
|
"id": list_id,
|
|
"name": list_obj.get("name"),
|
|
"hint": "Only user-created lists (rights.canDelete=true) can be deleted.",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
# Verified — delete in the same session.
|
|
# taskdeletelist uses 'id' (same pattern as metadelete / taskcategorydelete).
|
|
client.call("taskdeletelist", {"id": list_id})
|
|
client.logout()
|
|
except FamilyWallError as exc:
|
|
return f"Error: Family Wall API error: {exc}"
|
|
except Exception as exc:
|
|
return f"Error: Connection error: {exc}"
|
|
|
|
return json.dumps(
|
|
{"deleted": True, "id": list_id, "name": list_obj.get("name")},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool: like_post
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def like_post(post_id: str, like: bool = True) -> str:
|
|
"""Like a wall post/activity with a STAR mood.
|
|
|
|
Note: Unlike (like=False) is not yet supported. The Family Wall API offers
|
|
no discoverable endpoint or parameter to remove a like. Passing like=False
|
|
returns an error without making any API call.
|
|
|
|
Args:
|
|
post_id: Wall post ID from get_activities (e.g. ``wall/23431854_31119189``).
|
|
like: Must be ``True``. ``False`` is reserved for future unlike support.
|
|
|
|
Returns:
|
|
JSON success indicator or an error message.
|
|
"""
|
|
# Unlike is not yet supported: extensive FW_DEBUG=1 testing showed that
|
|
# wallmood with moodType="STAR" is an idempotent SET operation (not a toggle).
|
|
# Tested and ruled out: moodType variations ("NONE", "REMOVE", "DELETE", ""),
|
|
# moodStarShortcut parameter, and alternative endpoints (all return 502).
|
|
# See SPEC.md for full investigation notes.
|
|
if not like:
|
|
return json.dumps(
|
|
{"error": "Unlike is not yet supported. The unlike mechanism is unknown."},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
# Verified via FW_DEBUG=1:
|
|
# - Parameter 'wall_message_id': post ID as returned by get_activities
|
|
# - Parameter 'moodType': "STAR" (Family Wall's internal like type; "LIKE" is silently
|
|
# mapped to "STAR" server-side — use "STAR" directly)
|
|
# - Response a00.r.r: full wall message object with moodMap showing resulting state
|
|
params: dict[str, Any] = {
|
|
"wall_message_id": post_id,
|
|
"moodType": "STAR",
|
|
}
|
|
|
|
try:
|
|
data = _authenticated_call("wallmood", params)
|
|
except RuntimeError as exc:
|
|
return f"Error: {exc}"
|
|
|
|
# Extract moodMap from the response to confirm the like was recorded.
|
|
try:
|
|
wall_obj = data["a00"]["r"]["r"]
|
|
if not isinstance(wall_obj, dict):
|
|
raise TypeError("a00.r.r is not a dict")
|
|
mood_map: dict[str, Any] = wall_obj.get("moodMap") or {}
|
|
account_id: str = wall_obj.get("postAccountId", "")
|
|
except (KeyError, TypeError):
|
|
return json.dumps(
|
|
{"warning": "Unexpected wallmood response structure", "raw": data},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
# Two complementary indicators for the like state:
|
|
# - moodStarShortcut: direct boolean per-user flag on the post object (primary)
|
|
# - moodMap: dict of accountId → [mood types]; contains "STAR" when liked (secondary)
|
|
# Use both so either storage path is covered.
|
|
star_shortcut = wall_obj.get("moodStarShortcut") == "true"
|
|
star_in_map = any("STAR" in moods for moods in mood_map.values())
|
|
now_liked = star_shortcut or star_in_map
|
|
|
|
result: dict[str, Any] = {"liked": now_liked, "id": post_id, "author": account_id}
|
|
|
|
# Surface a warning when the like call apparently had no effect, so the
|
|
# caller can distinguish a successful like from a silent API rejection
|
|
# (e.g. rate limit, unsupported post type, or self-like restriction).
|
|
if not now_liked:
|
|
result["warning"] = (
|
|
"Like may not have been applied. "
|
|
"Possible causes: rate limit, unsupported post type (e.g. FAMILY_CREATED), "
|
|
"or self-like restriction."
|
|
)
|
|
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def create_server() -> FastMCP:
|
|
"""Return the configured Family Wall MCP server instance.
|
|
|
|
Returns:
|
|
FastMCP instance with all tools registered.
|
|
"""
|
|
return mcp
|