From 4c1e4e2c2341bcdddd5ea66082e6f9dd22097391 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Fri, 17 Apr 2026 22:41:16 +0200 Subject: [PATCH] feat(tasks): support reminder write via dot-notation (v1.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reminder_unit, reminder_value to create_task - Add reminder_unit, reminder_value, clear_reminder to update_task - Wire format verified: reminder.reminderUnit / .reminderValue / .reminderType (SNOOZE=active, NONE=clear) / .localId (optional) - Valid units: MINUTE, HOUR, DAY (WEEK rejected by enum decoder) - Clear requires full inactive block; partial updates reject with "task reminder invalid" - Flat top-level keys, JSON string and bracket notation are silently ignored by the server — confirmed via isolated fuzz per variant - Update SPEC.md, CLAUDE.md, README.md, CHANGELOG.md - Remove outdated "not supported" warning in update_task docstring --- CHANGELOG.md | 28 +++++++++++ CLAUDE.md | 8 +-- README.md | 4 +- SPEC.md | 31 ++++++++---- pyproject.toml | 2 +- src/mcp_familywall/server.py | 96 +++++++++++++++++++++++++++++++++--- 6 files changed, 147 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1676c03..9909967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This project follows Semantic Versioning (SemVer). Breaking changes (removed tools, changed parameters) increment the major version. +## [1.2.0] – 2026-04-17 + +### Added +- **Reminder write support** for `create_task` and `update_task`: + - `reminder_unit`: `"MINUTE"`, `"HOUR"`, or `"DAY"` + - `reminder_value`: non-negative integer (e.g. `30` for "30 minutes before") + - `clear_reminder` (update_task only): remove reminder +- The correct wire format uses **dot-notation** subfields + (`reminder.reminderUnit`, `reminder.reminderValue`, `reminder.reminderType`, + `reminder.localId`) — flat top-level keys, JSON-string, and bracket-notation + are silently ignored by the server. + +### Fixed +- `update_task` docstring: removed the "reminders not supported" warning — the + v1.1.2 write format was flat, which the FiZ encoder silently drops; the + dot-notation variant works on both Free and Premium accounts. + +### Investigation notes (2026-04-17) +- Verified via isolated fuzz test (fresh test task per variant): only dot-notation + succeeds; every other encoding is accepted with HTTP 200 and `lastAction: UPDATED` + but the reminder remains at default `{reminderType: NONE, reminderValue: 0}`. +- `reminder.localId` is optional. +- Partial block updates (e.g. only `reminder.reminderType=NONE`) return + `task reminder invalid` — the full inactive block must be sent to clear. +- Valid `reminderUnit` values: `MINUTE`, `HOUR`, `DAY`. `WEEK` is rejected by the + API enum decoder. +- Reminder + recurrency can be set in a single `taskupdate2` call without interference. + ## [1.1.2] – 2026-04-17 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index dd9c3ba..c003f30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ und wird in Claude Desktop eingebunden. ## Aktueller Stand -### Version: **v1.1.2** ← aktuell +### Version: **v1.2.0** ← aktuell ### Implementierte Tools @@ -50,6 +50,8 @@ und wird in Claude Desktop eingebunden. - v0.9: Task-Wiederholungen + Erinnerungen (read-only) - v0.10–v0.11: Essensplaner (read + write) - v1.0: Cleanup, Unified errors, Datumsvalidierung, Partial-Failure-Reporting (Details: CHANGELOG.md) +- v1.1: Task-Recurrency-Write (flat top-level params) +- v1.2: Task-Reminder-Write via Dot-Notation (`reminder.*`) — verifiziert 2026-04-17 ## Architektur-Entscheidungen @@ -133,8 +135,8 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level: | `taskupdate2` | `metaId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee`, `taskListId` | – | | `taskupdate2` | `dueDate` löschen | `$empty` | | `taskupdate2` | `recurrencyDescriptor` (flach!) | `recurrency, recurrencyInterval, rrule, byDay, byMonthDay, recurrencyEndDate, endOccurence` als Top-Level-Parameter; löschen: `recurrency="NONE"` | -| `taskupdate2` | **⚠️ Reminder read-only** | `reminderUnit`, `reminderValue`, `reminderType`, `localId` werden von der API ignoriert (verifiziert via FW_DEBUG auf Premium-Account). Service Worker der mobilen App transformiert Reminder-Requests — nicht reproduzierbar via direkter API. | -| `taskupdate2` | **⚠️ Encoding** | FiZ `Ai()`-Encoder sendet Recurrency-Felder flach (verifiziert). Reminder: read-only. | +| `taskupdate2` | Reminder (Dot-Notation!) | `reminder.reminderUnit` (`MINUTE`/`HOUR`/`DAY`), `reminder.reminderValue` (String-Integer), `reminder.reminderType` (`SNOOZE`=aktiv, `NONE`=entfernen), `reminder.localId` optional. Entfernen: vollständigen Block mit `reminderType=NONE, reminderValue="0", reminderUnit=MINUTE` senden. Partielle Updates → `task reminder invalid`. | +| `taskupdate2` | **⚠️ Encoding** | Recurrency flach top-level. Reminder **nur Dot-Notation** `reminder.*` — flache Keys, JSON-String, Brackets werden silent-ignored. | | `taskmark` | `taskId`, `complete` | `"true"`/`"false"` | | `metadelete` | `id` | metaId des Tasks / Rezepts | | `wallmood` | `wall_message_id`, `moodType` | `"STAR"` für Like | diff --git a/README.md b/README.md index 9e73c6b..f8ee7ec 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ MCP server for [Family Wall](https://www.familywall.com) — manage your family' | `delete_list` 🔒 | Permanently delete a list and all its tasks (system lists protected) | | `create_category` 🔒 | Create a custom category (with optional icon) | | `delete_category` 🔒 | Delete a custom category (system categories protected) | -| `create_task` 🔒 | Create a task (supports category, due date, assignees; use `"Äpfel (5x)"` for quantities) | -| `update_task` 🔒 | Update text, category, due date, assignees, or move to a different list | +| `create_task` 🔒 | Create a task (category, due date, assignees, reminder; use `"Äpfel (5x)"` for quantities) | +| `update_task` 🔒 | Update text, category, due date, assignees, recurrency, reminder, or move to a different list | | `toggle_task` 🔒 | Mark a task complete or reopen it | | `delete_task` 🔒 | Permanently delete a task | | `clear_list` 🔒 | Bulk-delete all tasks in a list (optional `only_open=True` keeps completed tasks) | diff --git a/SPEC.md b/SPEC.md index ca43a41..419f33b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -213,15 +213,28 @@ POST https://api.familywall.com/api/taskupdate2 **⚠️ Encoding:** Der FiZ-`Ai()`-Encoder serialisiert alle Felder **flach** als Top-Level-Form-Parameter. Recurrency-Felder werden direkt auf Top-Level gesendet (verifiziert via xb-Encoder im JS-Bundle). -**⚠️ Reminder-Schreibzugriff nicht möglich:** -Alle Versuche, `reminderUnit`, `reminderValue`, `reminderType`, `localId` via `taskupdate2` -zu setzen, wurden ignoriert — die API antwortet mit HTTP 200 und `lastAction: UPDATED`, -aber `reminder` bleibt unverändert auf dem Default-Wert `{reminderUnit: MINUTE, reminderType: NONE, reminderValue: 0}`. -Auch alternative Encodings (JSON-String, PHP-Bracket, verschachtelt) und alternative Endpoints -(`tasksetalert`, `taskalertput` etc.) wurden erprobt — alle Endpoints sind nicht registriert. -**Ursache:** Reminder-Updates werden vom Service Worker der mobilen App abgefangen und transformiert. -Diese Transformation ist nicht reproduzierbar ohne den Service Worker. -Reminder sind daher **read-only** (wie in v0.9 dokumentiert). +**Reminder-Schreibzugriff (verifiziert 2026-04-17):** + +Reminder werden via Dot-Notation als Unterfelder gesendet: + +| Parameter | Wert | +|---|---| +| `reminder.reminderUnit` | `"MINUTE"` \| `"HOUR"` \| `"DAY"` (WEEK wird abgelehnt) | +| `reminder.reminderValue` | nicht-negativer Integer als String (z.B. `"30"`) | +| `reminder.reminderType` | `"SNOOZE"` (aktiv) oder `"NONE"` (Reminder entfernen) | +| `reminder.localId` | optional; `"0"` akzeptiert | + +**Wichtig:** +- Nur Dot-Notation funktioniert. Flache Top-Level-Keys (`reminderUnit`, `reminderValue`, …), + JSON-String (`reminder={…}`) und Bracket-Notation (`reminder[reminderUnit]`) werden + **silent-ignored** (HTTP 200, Reminder unverändert). +- Partielle Reminder-Updates (z.B. nur `reminder.reminderType=NONE`) liefern + `task reminder invalid` — immer vollständigen Block senden. +- `reminder=$empty` wird silent-ignored; zum Entfernen muss `reminder.reminderType=NONE` + mit `reminder.reminderValue=0` und `reminder.reminderUnit=MINUTE` gesendet werden. +- Reminder-Felder lassen sich in einem einzigen `taskupdate2`-Call gemeinsam mit + `recurrency`/`dueDate`/`text` setzen — keine Interferenz. +- `$empty` auf Unterfelder (`reminder.reminderValue=$empty`) führt zu Parse-Fehlern. **Response:** ``` diff --git a/pyproject.toml b/pyproject.toml index b1ef814..e77e0c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-familywall" -version = "1.1.2" +version = "1.2.0" description = "MCP server for Family Wall — manage your family's circles, lists, tasks, recipes, and meal plan via Claude" readme = "README.md" requires-python = ">=3.12" diff --git a/src/mcp_familywall/server.py b/src/mcp_familywall/server.py index 4747047..a5bfd0b 100644 --- a/src/mcp_familywall/server.py +++ b/src/mcp_familywall/server.py @@ -807,6 +807,59 @@ def get_activities(limit: int = 20) -> str: # --------------------------------------------------------------------------- +# Reminder units accepted by the Family Wall API (verified 2026-04-17 via +# taskupdate2 fuzz: WEEK is rejected, MINUTE/HOUR/DAY succeed). +_VALID_REMINDER_UNITS = ("MINUTE", "HOUR", "DAY") + + +def _build_reminder_params( + reminder_unit: str | None, + reminder_value: int | None, + clear_reminder: bool, +) -> tuple[dict[str, Any], str | None]: + """Return (params, error_message). Params are the dot-notation reminder.* fields. + + Encoding note: taskupdate2 only accepts reminders via dot-notation + (``reminder.reminderUnit``, ``reminder.reminderValue``, ``reminder.reminderType``). + Flat top-level keys, JSON strings, and bracket notation are silently ignored + by the server — verified 2026-04-17. + + Clearing requires sending the full "inactive" block + (reminderType=NONE, reminderValue=0, reminderUnit=MINUTE, localId=0) — + partial updates return ``"task reminder invalid"``. + """ + if clear_reminder and (reminder_unit is not None or reminder_value is not None): + return {}, "'clear_reminder' cannot be combined with 'reminder_unit' or 'reminder_value'." + + if clear_reminder: + return { + "reminder.reminderUnit": "MINUTE", + "reminder.reminderValue": "0", + "reminder.reminderType": "NONE", + "reminder.localId": "0", + }, None + + if reminder_unit is None and reminder_value is None: + return {}, None + + if reminder_unit is None or reminder_value is None: + return {}, "'reminder_unit' and 'reminder_value' must be provided together." + + if reminder_unit not in _VALID_REMINDER_UNITS: + allowed = ", ".join(_VALID_REMINDER_UNITS) + return {}, f"Invalid reminder_unit {reminder_unit!r}. Allowed: {allowed}." + + if reminder_value < 0: + return {}, "'reminder_value' must be non-negative." + + return { + "reminder.reminderUnit": reminder_unit, + "reminder.reminderValue": str(reminder_value), + "reminder.reminderType": "SNOOZE", + "reminder.localId": "0", + }, None + + def _authenticated_call(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: """Login, call *endpoint* with *params*, logout, and return the response body. @@ -847,6 +900,8 @@ def create_task( category_id: str | None = None, due_date: str | None = None, assignee_ids: list[str] | None = None, + reminder_unit: str | None = None, + reminder_value: int | None = None, ) -> str: """Create a new task in the given list. @@ -878,10 +933,20 @@ def create_task( 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. + reminder_unit: Reminder time unit — ``"MINUTE"``, ``"HOUR"``, or ``"DAY"``. + Must be provided together with *reminder_value*. Requires Family Wall Premium. + reminder_value: Reminder offset before the due date (e.g. ``30`` for + "30 minutes before"). Must be provided together with *reminder_unit*. Returns: JSON with the new task's metaId on success, or an error message. """ + reminder_params, reminder_error = _build_reminder_params( + reminder_unit, reminder_value, clear_reminder=False + ) + if reminder_error: + return _err(reminder_error) + params: dict[str, Any] = {"taskListId": list_id, "text": text} if description: params["description"] = description @@ -891,6 +956,7 @@ def create_task( params["dueDate"] = due_date if assignee_ids is not None: params["assignee"] = assignee_ids if assignee_ids else "" + params.update(reminder_params) try: data = _authenticated_call("taskcreate2", params) @@ -930,17 +996,15 @@ def update_task( recurrency_interval: int | None = None, rrule: str | None = None, clear_recurrency: bool = False, + reminder_unit: str | None = None, + reminder_value: int | None = None, + clear_reminder: bool = False, ) -> 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. - NOTE: Setting or clearing reminders is **not supported** via this tool. - The Family Wall API does not accept reminder writes through ``taskupdate2`` - — reminder updates are handled exclusively by the mobile app's Service - Worker and cannot be replicated via direct API calls. - Args: task_id: Task metaId from get_tasks. text: New title text (omit to leave unchanged). @@ -969,6 +1033,13 @@ def update_task( Overrides simple recurrency fields when set. clear_recurrency: Set to ``True`` to remove the recurrence rule from the task. Cannot be used together with *recurrency*. + reminder_unit: Reminder time unit — ``"MINUTE"``, ``"HOUR"``, or ``"DAY"``. + Must be provided together with *reminder_value*. Requires Family Wall + Premium. Cannot be used together with *clear_reminder*. + reminder_value: Reminder offset before the due date (e.g. ``30`` for + "30 minutes before"). Must be provided together with *reminder_unit*. + clear_reminder: Set to ``True`` to remove the reminder from the task. + Cannot be used together with *reminder_unit* or *reminder_value*. Returns: JSON success indicator or an error message. @@ -978,6 +1049,12 @@ def update_task( if clear_recurrency and recurrency is not None: return _err("'clear_recurrency' and 'recurrency' cannot be used together.") + reminder_params, reminder_error = _build_reminder_params( + reminder_unit, reminder_value, clear_reminder + ) + if reminder_error: + return _err(reminder_error) + if ( text is None and description is None @@ -988,11 +1065,13 @@ def update_task( and list_id is None and recurrency is None and not clear_recurrency + and not reminder_params ): return _err( "At least one of 'text', 'description', 'category_id', 'due_date'," - " 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency', or" - " 'clear_recurrency' must be provided." + " 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency'," + " 'clear_recurrency', 'reminder_unit'/'reminder_value', or" + " 'clear_reminder' must be provided." ) params: dict[str, Any] = {"metaId": task_id} @@ -1022,6 +1101,9 @@ def update_task( if rrule is not None: params["rrule"] = rrule + # Reminder fields use dot-notation `reminder.*` (verified: flat keys ignored). + params.update(reminder_params) + try: _authenticated_call("taskupdate2", params) except RuntimeError as exc: