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).
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
+5 -3
View File
@@ -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.10v0.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 |
+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) |
| `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) |
+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.
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:**
```
+1 -1
View File
@@ -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"
+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]:
"""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: