From 70b1f73079d7b7d8fcb442d47c3334eb92a62a25 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Wed, 15 Apr 2026 16:22:59 +0200 Subject: [PATCH] feat: add get_members tool, refactor get_circles via _famlistfamily helper (v0.4.7) famlistfamily response already contains members[] on each circle object. get_members(circle_id=None) extracts id, name, email, role, right, color, avatar, circle_id and circle_name. get_circles refactored to use the new _famlistfamily() helper, eliminating duplicated auth/call/logout logic. SPEC.md updated with full famlistfamily response structure. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +- SPEC.md | 27 ++++++++- pyproject.toml | 2 +- src/mcp_familywall/server.py | 113 +++++++++++++++++++++++++++++++---- 4 files changed, 128 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ec16e87..9e00dcf 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ 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.6) +## Features (v0.4.7) ### 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_activities` -- list recent wall activities diff --git a/SPEC.md b/SPEC.md index cfb01ac..2a82dbf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -82,9 +82,30 @@ Content-Type: application/x-www-form-urlencoded **Response-Struktur (verifiziert):** ``` -a00.r.r[] → Kreise - .metaId → eindeutige Kreis-ID - .name → Kreisname +a00.r.r[] → Kreise + .metaId → eindeutige Kreis-ID (Format family/) + .name → Kreisname + .family_id → numerische Kreis-ID + .members[] → Mitglieder des Kreises + .accountId → numerische Account-ID + .metaId → Mitglieds-ID (Format familymember/_) + .firstName → Vorname (Display-Name; bevorzugen gegenüber .name) + .name → E-Mail-Adresse (Family Wall Default wenn kein Anzeigename) + .role → Familienrolle (z.B. "Unknown", "Parent", "Child") + .right → Berechtigung (z.B. "SuperAdmin", "Admin", "Member") + .color → Profilfarbe als Hex-String (z.B. "#FF8086") + .medias[0].pictureUrl → Avatar-URL (generierter Default wenn pictureDefault=true) + .identifiers[] → Kontaktdaten + .type → Typ (z.B. "Email") + .value → Wert (z.B. E-Mail-Adresse) + .familyId → Zugehöriger Kreis (= metaId des Kreises) + .isloggedaccount → "true" wenn das der angemeldete Account ist + .joinDate → Beitrittsdatum (ISO 8601) + .lastLoginDate → Letzter Login (ISO 8601) + .locale → Spracheinstellung (z.B. "de_DE") + .timeZone → Zeitzone (z.B. "Europe/Berlin") + .invitations[] → Offene Einladungen (leer wenn keine) + .coverUri → Cover-Bild URL ``` ### `taskgettasklists` – Listen abrufen diff --git a/pyproject.toml b/pyproject.toml index 0346725..94dd6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.4.6" +version = "0.4.7" 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/server.py b/src/mcp_familywall/server.py index 5ff2e16..fdb3ec2 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -121,32 +121,121 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]: def get_circles(): """Return all Family Wall circles as JSON list of {id, name}.""" try: - email, password = get_credentials() + 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: - return f"Error: {exc}" + raise RuntimeError(f"Family Wall API error: {exc}") from exc except Exception as exc: - return f"Connection error: {exc}" + raise RuntimeError(f"Connection error: {exc}") from exc try: - raw_circles = data["a00"]["r"]["r"] - if not isinstance(raw_circles, list): + raw = data["a00"]["r"]["r"] + if not isinstance(raw, list): raise TypeError("a00.r.r is not a list") - except (KeyError, TypeError): - return json.dumps( - {"warning": "Unexpected famlistfamily response structure", "raw": data}, - ensure_ascii=False, - indent=2, - ) + 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, + } + ) - result = [{"id": c.get("metaId"), "name": c.get("name")} for c in raw_circles] return json.dumps(result, ensure_ascii=False, indent=2)