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 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 16:22:59 +02:00
parent 4cd0bdc499
commit 70b1f73079
4 changed files with 128 additions and 17 deletions
+2 -1
View File
@@ -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. 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 ### Read
- `get_circles` -- list all family circles - `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_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
- `get_activities` -- list recent wall activities - `get_activities` -- list recent wall activities
+24 -3
View File
@@ -82,9 +82,30 @@ Content-Type: application/x-www-form-urlencoded
**Response-Struktur (verifiziert):** **Response-Struktur (verifiziert):**
``` ```
a00.r.r[] → Kreise a00.r.r[] → Kreise
.metaId → eindeutige Kreis-ID .metaId → eindeutige Kreis-ID (Format family/<id>)
.name → Kreisname .name → Kreisname
.family_id → numerische Kreis-ID
.members[] → Mitglieder des Kreises
.accountId → numerische Account-ID
.metaId → Mitglieds-ID (Format familymember/<accountId>_<familyId>)
.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 ### `taskgettasklists` Listen abrufen
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcp-familywall" 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" description = "MCP server for Family Wall — read your family's lists and tasks via Claude"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+101 -12
View File
@@ -121,32 +121,121 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
def get_circles(): def get_circles():
"""Return all Family Wall circles as JSON list of {id, name}.""" """Return all Family Wall circles as JSON list of {id, name}."""
try: try:
email, password = get_credentials() raw_circles = _famlistfamily()
except RuntimeError as exc: except RuntimeError as exc:
return f"Error: {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: try:
with FamilyWallClient() as client: with FamilyWallClient() as client:
client.login(email, password) client.login(email, password)
data = client.call("famlistfamily") data = client.call("famlistfamily")
client.logout() client.logout()
except FamilyWallError as exc: except FamilyWallError as exc:
return f"Error: {exc}" raise RuntimeError(f"Family Wall API error: {exc}") from exc
except Exception as exc: except Exception as exc:
return f"Connection error: {exc}" raise RuntimeError(f"Connection error: {exc}") from exc
try: try:
raw_circles = data["a00"]["r"]["r"] raw = data["a00"]["r"]["r"]
if not isinstance(raw_circles, list): if not isinstance(raw, list):
raise TypeError("a00.r.r is not a list") raise TypeError("a00.r.r is not a list")
except (KeyError, TypeError): return raw
return json.dumps( except (KeyError, TypeError) as exc:
{"warning": "Unexpected famlistfamily response structure", "raw": data}, raise RuntimeError(f"Unexpected famlistfamily response structure: {exc}") from exc
ensure_ascii=False,
indent=2,
) # ---------------------------------------------------------------------------
# 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) return json.dumps(result, ensure_ascii=False, indent=2)