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
+101 -12
View File
@@ -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)