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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user