feat(tasks): support reminder write via dot-notation (v1.2.0)

- 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
This commit is contained in:
2026-04-17 22:41:16 +02:00
parent d6d8d40305
commit 4c1e4e2c23
6 changed files with 147 additions and 22 deletions
+28
View File
@@ -10,6 +10,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
This project follows Semantic Versioning (SemVer). This project follows Semantic Versioning (SemVer).
Breaking changes (removed tools, changed parameters) increment the major version. 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 ## [1.1.2] 2026-04-17
### Fixed ### Fixed
+5 -3
View File
@@ -27,7 +27,7 @@ und wird in Claude Desktop eingebunden.
## Aktueller Stand ## Aktueller Stand
### Version: **v1.1.2** ← aktuell ### Version: **v1.2.0** ← aktuell
### Implementierte Tools ### Implementierte Tools
@@ -50,6 +50,8 @@ und wird in Claude Desktop eingebunden.
- v0.9: Task-Wiederholungen + Erinnerungen (read-only) - v0.9: Task-Wiederholungen + Erinnerungen (read-only)
- v0.10v0.11: Essensplaner (read + write) - v0.10v0.11: Essensplaner (read + write)
- v1.0: Cleanup, Unified errors, Datumsvalidierung, Partial-Failure-Reporting (Details: CHANGELOG.md) - 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 ## 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` | `metaId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee`, `taskListId` | |
| `taskupdate2` | `dueDate` löschen | `$empty` | | `taskupdate2` | `dueDate` löschen | `$empty` |
| `taskupdate2` | `recurrencyDescriptor` (flach!) | `recurrency, recurrencyInterval, rrule, byDay, byMonthDay, recurrencyEndDate, endOccurence` als Top-Level-Parameter; löschen: `recurrency="NONE"` | | `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` | 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** | FiZ `Ai()`-Encoder sendet Recurrency-Felder flach (verifiziert). Reminder: read-only. | | `taskupdate2` | **⚠️ Encoding** | Recurrency flach top-level. Reminder **nur Dot-Notation** `reminder.*` — flache Keys, JSON-String, Brackets werden silent-ignored. |
| `taskmark` | `taskId`, `complete` | `"true"`/`"false"` | | `taskmark` | `taskId`, `complete` | `"true"`/`"false"` |
| `metadelete` | `id` | metaId des Tasks / Rezepts | | `metadelete` | `id` | metaId des Tasks / Rezepts |
| `wallmood` | `wall_message_id`, `moodType` | `"STAR"` für Like | | `wallmood` | `wall_message_id`, `moodType` | `"STAR"` für Like |
+2 -2
View File
@@ -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) | | `delete_list` 🔒 | Permanently delete a list and all its tasks (system lists protected) |
| `create_category` 🔒 | Create a custom category (with optional icon) | | `create_category` 🔒 | Create a custom category (with optional icon) |
| `delete_category` 🔒 | Delete a custom category (system categories protected) | | `delete_category` 🔒 | Delete a custom category (system categories protected) |
| `create_task` 🔒 | Create a task (supports category, due date, assignees; use `"Äpfel (5x)"` for quantities) | | `create_task` 🔒 | Create a task (category, due date, assignees, reminder; use `"Äpfel (5x)"` for quantities) |
| `update_task` 🔒 | Update text, category, due date, assignees, or move to a different list | | `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 | | `toggle_task` 🔒 | Mark a task complete or reopen it |
| `delete_task` 🔒 | Permanently delete a task | | `delete_task` 🔒 | Permanently delete a task |
| `clear_list` 🔒 | Bulk-delete all tasks in a list (optional `only_open=True` keeps completed tasks) | | `clear_list` 🔒 | Bulk-delete all tasks in a list (optional `only_open=True` keeps completed tasks) |
+22 -9
View File
@@ -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. **⚠️ 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). Recurrency-Felder werden direkt auf Top-Level gesendet (verifiziert via xb-Encoder im JS-Bundle).
**⚠️ Reminder-Schreibzugriff nicht möglich:** **Reminder-Schreibzugriff (verifiziert 2026-04-17):**
Alle Versuche, `reminderUnit`, `reminderValue`, `reminderType`, `localId` via `taskupdate2`
zu setzen, wurden ignoriert — die API antwortet mit HTTP 200 und `lastAction: UPDATED`, Reminder werden via Dot-Notation als Unterfelder gesendet:
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 | Parameter | Wert |
(`tasksetalert`, `taskalertput` etc.) wurden erprobt — alle Endpoints sind nicht registriert. |---|---|
**Ursache:** Reminder-Updates werden vom Service Worker der mobilen App abgefangen und transformiert. | `reminder.reminderUnit` | `"MINUTE"` \| `"HOUR"` \| `"DAY"` (WEEK wird abgelehnt) |
Diese Transformation ist nicht reproduzierbar ohne den Service Worker. | `reminder.reminderValue` | nicht-negativer Integer als String (z.B. `"30"`) |
Reminder sind daher **read-only** (wie in v0.9 dokumentiert). | `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:** **Response:**
``` ```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcp-familywall" 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" description = "MCP server for Family Wall — manage your family's circles, lists, tasks, recipes, and meal plan via Claude"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+89 -7
View File
@@ -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]: def _authenticated_call(endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
"""Login, call *endpoint* with *params*, logout, and return the response body. """Login, call *endpoint* with *params*, logout, and return the response body.
@@ -847,6 +900,8 @@ def create_task(
category_id: str | None = None, category_id: str | None = None,
due_date: str | None = None, due_date: str | None = None,
assignee_ids: list[str] | None = None, assignee_ids: list[str] | None = None,
reminder_unit: str | None = None,
reminder_value: int | None = None,
) -> str: ) -> str:
"""Create a new task in the given list. """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"``). 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 assignee_ids: Optional list of member IDs from get_members to assign the task
(e.g. ``["23431898"]``). Empty list assigns to nobody. (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: Returns:
JSON with the new task's metaId on success, or an error message. 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} params: dict[str, Any] = {"taskListId": list_id, "text": text}
if description: if description:
params["description"] = description params["description"] = description
@@ -891,6 +956,7 @@ def create_task(
params["dueDate"] = due_date params["dueDate"] = due_date
if assignee_ids is not None: if assignee_ids is not None:
params["assignee"] = assignee_ids if assignee_ids else "" params["assignee"] = assignee_ids if assignee_ids else ""
params.update(reminder_params)
try: try:
data = _authenticated_call("taskcreate2", params) data = _authenticated_call("taskcreate2", params)
@@ -930,17 +996,15 @@ def update_task(
recurrency_interval: int | None = None, recurrency_interval: int | None = None,
rrule: str | None = None, rrule: str | None = None,
clear_recurrency: bool = False, clear_recurrency: bool = False,
reminder_unit: str | None = None,
reminder_value: int | None = None,
clear_reminder: bool = False,
) -> str: ) -> str:
"""Update an existing task's fields. """Update an existing task's fields.
IMPORTANT: Ask the user for confirmation before calling this tool. IMPORTANT: Ask the user for confirmation before calling this tool.
At least one parameter besides *task_id* must be provided. 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: Args:
task_id: Task metaId from get_tasks. task_id: Task metaId from get_tasks.
text: New title text (omit to leave unchanged). text: New title text (omit to leave unchanged).
@@ -969,6 +1033,13 @@ def update_task(
Overrides simple recurrency fields when set. Overrides simple recurrency fields when set.
clear_recurrency: Set to ``True`` to remove the recurrence rule from the task. clear_recurrency: Set to ``True`` to remove the recurrence rule from the task.
Cannot be used together with *recurrency*. 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: Returns:
JSON success indicator or an error message. JSON success indicator or an error message.
@@ -978,6 +1049,12 @@ def update_task(
if clear_recurrency and recurrency is not None: if clear_recurrency and recurrency is not None:
return _err("'clear_recurrency' and 'recurrency' cannot be used together.") 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 ( if (
text is None text is None
and description is None and description is None
@@ -988,11 +1065,13 @@ def update_task(
and list_id is None and list_id is None
and recurrency is None and recurrency is None
and not clear_recurrency and not clear_recurrency
and not reminder_params
): ):
return _err( return _err(
"At least one of 'text', 'description', 'category_id', 'due_date'," "At least one of 'text', 'description', 'category_id', 'due_date',"
" 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency', or" " 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency',"
" 'clear_recurrency' must be provided." " 'clear_recurrency', 'reminder_unit'/'reminder_value', or"
" 'clear_reminder' must be provided."
) )
params: dict[str, Any] = {"metaId": task_id} params: dict[str, Any] = {"metaId": task_id}
@@ -1022,6 +1101,9 @@ def update_task(
if rrule is not None: if rrule is not None:
params["rrule"] = rrule params["rrule"] = rrule
# Reminder fields use dot-notation `reminder.*` (verified: flat keys ignored).
params.update(reminder_params)
try: try:
_authenticated_call("taskupdate2", params) _authenticated_call("taskupdate2", params)
except RuntimeError as exc: except RuntimeError as exc: