From 2bc03e216565a18068275a9b2a5d6a5e35edef54 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 16 Apr 2026 17:59:20 +0200 Subject: [PATCH] feat(circles): create_circle + add_member_to_circle (v0.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acccreatefamily endpoint creates a new circle (returns numeric ID) - accinvite endpoint invites new users by email (familyId, identifier, role, firstname) - fw_client now detects a00.ex errors (was only checking a00.un before) - New modules/circles.py with FamilyRoleTypeEnum constants - SPEC.md updated with acccreatefamily, accinvite, accupdatefamily docs - Note: circle deletion not supported by FW API (metadelete → "delete not supported") - Note: accinvite only works for new (non-existing) FW accounts Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 13 ++- README.md | 4 +- SPEC.md | 71 +++++++++++- pyproject.toml | 2 +- src/mcp_familywall/__init__.py | 2 +- src/mcp_familywall/fw_client.py | 17 +-- src/mcp_familywall/modules/circles.py | 10 ++ src/mcp_familywall/server.py | 150 ++++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 src/mcp_familywall/modules/circles.py diff --git a/CLAUDE.md b/CLAUDE.md index 98c0948..4cf3ce6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ und wird in Claude Desktop eingebunden. ## Aktueller Stand -### Implementierte Tools (v0.6.1) +### Implementierte Tools (v0.7.0) | Kategorie | Tools | |---|---| @@ -34,6 +34,7 @@ und wird in Claude Desktop eingebunden. | Kategorien | `create_category`, `delete_category` | | Aktivitäten | `like_post` | | Rezepte | `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe` | +| Kreise | `create_circle`, `add_member_to_circle` | ## Roadmap @@ -43,10 +44,11 @@ und wird in Claude Desktop eingebunden. - v0.5.2: Mengenkonvention im create_task Docstring ✓ - v0.5.3: Kategorie-Auto-Assign-Hinweis im create_task Docstring ✓ (nachgeliefert in v0.6.1) - v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓ -- v0.6.1: update_recipe + Bugfix Zeilenumbrüche in create_recipe ✓ ← aktuell -- v0.6.2: mpadditemtolist (Zutaten → Einkaufsliste) +- v0.6.1: update_recipe + Bugfix Zeilenumbrüche in create_recipe ✓ +- v0.7.0: create_circle + add_member_to_circle ✓ ← aktuell +- v0.7.1: mpadditemtolist (Zutaten → Einkaufsliste) - v0.5.3: update_list (Umbenennen, emoji/color ändern), Sharing-Verwaltung -- v0.7.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) +- v0.8.x: Erinnerungen + Wiederholungen (Premium-Account erforderlich) - v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren) @@ -136,6 +138,9 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level: | `mprecipeput` | `recipe.name`, `recipe.isRecipe="true"`, `recipe.description`, `recipe.ingredients`, `recipe.instructions`, `recipe.prepTime`, `recipe.cookTime`, `recipe.serves`, `recipe.url` | Alle mit `recipe.`-Prefix! | | `mprecipeput` (Update) | zusätzlich `recipe.metaId` | Vorhandene ID → Update statt Create | | `metasync` (Rezepte lesen) | `id="recipe"` | liefert `a00.r.r.updatedCreated[]` | +| `acccreatefamily` | `name` | liefert numerische Kreis-ID als String in `a00.r.r` | +| `accinvite` | `familyId`, `identifier`, `role="Unknown"`, `firstname` | nur für neue FW-Accounts | +| `accupdatefamily` | `name` | aktualisiert immer den PRIMARY Kreis (ignoriert `id`/`familyId`) | ### Self-Like-Restriction Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts. diff --git a/README.md b/README.md index 256aed9..db41b62 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your family's circles, lists, tasks, and recipes directly from Claude. -## Features (v0.6.1) +## Features (v0.7.0) ### Read @@ -29,6 +29,8 @@ MCP server for [Family Wall](https://www.familywall.com) -- read and manage your - `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url); use `\n` to separate ingredient lines - `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged) - `delete_recipe` -- permanently delete a recipe (only own recipes) +- `create_circle` -- create a new Family Wall circle (group); note: circles cannot be deleted via API +- `add_member_to_circle` -- invite a person to a circle by e-mail (for new Family Wall users only; existing accounts require in-app invitation) ## Requirements diff --git a/SPEC.md b/SPEC.md index 54fa33d..0cf29ee 100644 --- a/SPEC.md +++ b/SPEC.md @@ -491,11 +491,80 @@ a00.r.r → "true" (String) **Verifiziert am:** 2026-04-16 via FW_DEBUG=1 +### `acccreatefamily` – Kreis erstellen +POST https://api.familywall.com/api/acccreatefamily + +**Body-Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `name` | ja | Kreis-Name | + +**Response:** +``` +a00.r.r → numerische Kreis-ID als String (z.B. "23447369") + → vollständige metaId: "family/" + id +``` + +**Hinweise:** +- Der Server kapitalisiert den ersten Buchstaben des Namens. +- Kreise können über die API nicht gelöscht werden (`metadelete` → "delete not supported"). +- Vollständige Kreisdaten (incl. Name) via `famlistfamily` im gleichen Session-Call abrufbar. + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + +### `accinvite` – Mitglied einladen +POST https://api.familywall.com/api/accinvite + +**Body-Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `familyId` | ja | Kreis-metaId (z.B. `"family/23447369"`) | +| `identifier` | ja | E-Mail-Adresse des Eingeladenen | +| `role` | ja | `"Unknown"` (einziger verifizierter Enum-Wert für `FamilyRoleTypeEnum`) | +| `firstname` | ja | Vorname des Eingeladenen | + +**Einschränkung:** Der Endpoint funktioniert nur für Personen, die noch kein +Family Wall-Konto haben. Bei bestehenden Accounts antwortet der Server mit: +`"This api is now only used to create and invite an account with login only."` + +**Fehler-Struktur** (HTTP 200, aber Fehler unter `a00.ex`):** +```json +{"a00": {"ex": {"ex": {"FiZClassId": "17", "message": "..."}}}} +``` +→ `fw_client.py` prüft `a00.ex` und wirft `FamilyWallError`. + +**Response (Erfolg):** +``` +a00.r.r → Einladungsobjekt (Struktur unbekannt, da nicht testbar mit Test-Account) +``` + +**Bekannte Enum-Werte für `role`:** +- `"Unknown"` ✓ verifiziert +- `"Parent"`, `"Child"` → `FamilyRoleTypeEnum` decode error + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + +### `accupdatefamily` – Kreis umbenennen +POST https://api.familywall.com/api/accupdatefamily + +**Body-Parameter:** + +| Parameter | Pflicht | Wert | +|---|---|---| +| `name` | ja | Neuer Kreis-Name | + +**Hinweis:** Aktualisiert immer den PRIMARY Kreis des Accounts (ignoriert `id`/`familyId` Parameter). + +**Verifiziert am:** 2026-04-16 via FW_DEBUG=1 + ## Offene Punkte - Unlike-Endpoint (Service Worker blockiert Analyse) - Erinnerungen (reminder) – nur Premium-Account - Wiederholungen (repeat) – nur Premium-Account - Sortierung von Kategorien via API -- update_recipe (Rezept aktualisieren) – Endpoint: mprecipeput mit metaId - mpadditemtolist (Zutaten aus Rezept → Einkaufsliste) +- Einladung bestehender FamilyWall-Nutzer (accinvite nur für neue Accounts) +- Kreis-Delete-Endpoint (API: "delete not supported" für family-Objekte) diff --git a/pyproject.toml b/pyproject.toml index ff11818..9c1f42a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "0.6.1" +version = "0.7.0" 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/__init__.py b/src/mcp_familywall/__init__.py index 43c4ab0..49e0fc1 100644 --- a/src/mcp_familywall/__init__.py +++ b/src/mcp_familywall/__init__.py @@ -1 +1 @@ -__version__ = "0.6.1" +__version__ = "0.7.0" diff --git a/src/mcp_familywall/fw_client.py b/src/mcp_familywall/fw_client.py index 6b59e76..8d9ec48 100644 --- a/src/mcp_familywall/fw_client.py +++ b/src/mcp_familywall/fw_client.py @@ -185,12 +185,17 @@ class FamilyWallClient: msg = f"API error from {endpoint!r}: {error_data}" raise FamilyWallError(msg, response_data=body) - # Some endpoints (e.g. taskupdate2) return per-call errors nested under - # a00.un.un instead of top-level — detect and surface them. + # Some endpoints return per-call errors nested under a00.un.un or a00.ex.ex + # instead of top-level — detect and surface them. a00 = body.get("a00", {}) - if isinstance(a00, dict) and "un" in a00: - nested = a00["un"] - msg = f"API error from {endpoint!r}: {nested}" - raise FamilyWallError(msg, response_data=body) + if isinstance(a00, dict): + if "un" in a00: + nested = a00["un"] + msg = f"API error from {endpoint!r}: {nested}" + raise FamilyWallError(msg, response_data=body) + if "ex" in a00: + nested = a00["ex"] + msg = f"API error from {endpoint!r}: {nested}" + raise FamilyWallError(msg, response_data=body) return body diff --git a/src/mcp_familywall/modules/circles.py b/src/mcp_familywall/modules/circles.py new file mode 100644 index 0000000..650e3cd --- /dev/null +++ b/src/mcp_familywall/modules/circles.py @@ -0,0 +1,10 @@ +"""Circle (family group) helpers for the Family Wall MCP server.""" + +from __future__ import annotations + +# Valid family role types known to the Family Wall API (FamilyRoleTypeEnum). +# Only "Unknown" has been verified to be accepted by accinvite. +ROLE_UNKNOWN = "Unknown" + +# Default role used when inviting new members. +DEFAULT_INVITE_ROLE = ROLE_UNKNOWN diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index c76c548..8dd3fa4 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -10,6 +10,7 @@ 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.circles import DEFAULT_INVITE_ROLE from mcp_familywall.modules.lists import translate_name from mcp_familywall.modules.recipes import ( build_create_params, @@ -1051,6 +1052,155 @@ def delete_list(list_id: str) -> str: ) +# --------------------------------------------------------------------------- +# Tool: create_circle +# --------------------------------------------------------------------------- + + +@mcp.tool() +def create_circle(name: str) -> str: + """Create a new Family Wall circle (group). + + IMPORTANT: Ask the user for confirmation before calling this tool. + + A circle is a group of people who share content on Family Wall (e.g. a + family, a group of friends). After creation the caller is automatically + added as the circle owner (SuperAdmin). + + Note: Circle deletion is not supported by the Family Wall API. Test + circles must be deleted manually in the Family Wall app settings. + + Args: + name: Display name for the new circle (e.g. ``"Van Elst"``). + + Returns: + JSON with ``{created, id, name}`` on success, or an error message. + The server may capitalise the first letter of the name. + """ + try: + email, password = get_credentials() + except RuntimeError as exc: + return f"Error: {exc}" + + try: + with FamilyWallClient() as client: + client.login(email, password) + + # acccreatefamily returns only the numeric family ID. + data = client.call("acccreatefamily", {"name": name}) + try: + numeric_id: str = data["a00"]["r"]["r"] + if not isinstance(numeric_id, str) or not numeric_id.isdigit(): + raise TypeError(f"expected numeric ID, got {numeric_id!r}") + except (KeyError, TypeError): + client.logout() + return json.dumps( + {"warning": "Unexpected acccreatefamily response", "raw": data}, + ensure_ascii=False, + indent=2, + ) + + circle_id = f"family/{numeric_id}" + + # Read back the circle to obtain the server-stored (possibly + # capitalised) name in the same session. + canonical_name = name + try: + circles_data = client.call("famlistfamily") + raw_circles = circles_data.get("a00", {}).get("r", {}).get("r", []) or [] + for c in raw_circles: + if c.get("metaId") == circle_id: + canonical_name = c.get("name", name) + break + except FamilyWallError: + pass # fallback: use caller-provided name + + 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( + {"created": True, "id": circle_id, "name": canonical_name}, + ensure_ascii=False, + indent=2, + ) + + +# --------------------------------------------------------------------------- +# Tool: add_member_to_circle +# --------------------------------------------------------------------------- + + +@mcp.tool() +def add_member_to_circle( + circle_id: str, + email: str, + firstname: str | None = None, +) -> str: + """Invite a person to a Family Wall circle by e-mail. + + IMPORTANT: Ask the user for confirmation before calling this tool. + + Sends an invitation to the given e-mail address. The recipient will + receive an e-mail with a link to join the circle. If they already have a + Family Wall account they must accept the invitation via the app; if they + do not have an account yet one will be created during the acceptance flow. + + Note: The Family Wall API ``accinvite`` endpoint is limited to inviting + people who do not yet have an active Family Wall account. Inviting an + existing account holder returns an error from the server. + + Args: + circle_id: Target circle metaId from ``get_circles`` + (e.g. ``"family/23431854"``). + email: E-mail address of the person to invite. + firstname: First name of the invitee (used in the invitation e-mail). + When omitted the local part of the e-mail address is used as a + fallback (e.g. ``"john"`` from ``john.doe@example.com``). + + Returns: + JSON success indicator or an error message. + """ + if not email or "@" not in email: + return "Error: 'email' must be a valid e-mail address." + + # Derive a sensible first-name fallback from the email local part. + if firstname is None: + local_part = email.split("@")[0] + firstname = local_part.split(".")[0].capitalize() + + params: dict[str, Any] = { + "familyId": circle_id, + "identifier": email, + "role": DEFAULT_INVITE_ROLE, + "firstname": firstname, + } + + try: + data = _authenticated_call("accinvite", params) + except RuntimeError as exc: + return f"Error: {exc}" + + # On success the server returns the invitation object under a00.r.r. + try: + result_obj = data.get("a00", {}).get("r", {}).get("r") + except (AttributeError, TypeError): + result_obj = None + + return json.dumps( + { + "invited": True, + "circle_id": circle_id, + "email": email, + "result": result_obj, + }, + ensure_ascii=False, + indent=2, + ) + + # --------------------------------------------------------------------------- # Tool: like_post # ---------------------------------------------------------------------------