14 Commits

Author SHA1 Message Date
marcus b8dec599a3 Versionsnummer in README.md auf v1.4.4 angehoben 2026-04-20 06:12:36 +02:00
marcus 5f5abfcbc7 docs(usability): reorganize README.md tools by category — move get_activities and like_post to Wall & Activities (v1.4.4)
- Consolidate wall post and activity tools in dedicated section
- Remove duplicate entries from Lists & Tasks
- Update README version header to v1.4.3
- Bump pyproject.toml to v1.4.4
- Fix unused import in find_category_param.py

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-20 06:00:55 +02:00
marcus 5671d70000 docs(usability): enforce ID→name resolution in all tool docstrings (v1.4.3)
Add explicit guidance to resolve raw API IDs to human-readable names before
user display. Affected tools: get_tasks, get_lists, get_activities,
get_wall_posts, get_members, get_circles, get_categories, get_meal_plan.

Add general usability rule to CLAUDE.md implementation section: never present
raw numeric/metaIDs to the user. No breaking changes; docstring improvements only.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-18 00:10:26 +02:00
marcus 0e34b067e6 fix(get_tasks): support for secondary circles by deriving scope from list_id (v1.4.2) 2026-04-18 00:03:38 +02:00
marcus dd42dc2845 docs(docstrings): improve tool usage guidance for new Claude sessions (v1.4.1)
- get_wall_posts: clarify mixed content (status posts + activity entries)
- get_activities: expand description and distinguish from get_wall_posts
- like_post: extend post_id examples (wall/, task/, taskList/ IDs)
- get_recipe_categories: add usage notes (IDs, free-tier categories, family-wide)
- add_meal_note: clarify create-only behavior (delete then recreate to update)
- clear_list: move risk warning (default deletes all) to opening line
- create_recipe: verify IMPORTANT confirmation line present

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 23:47:42 +02:00
marcus 09bd24a9e1 feat(tests): add comprehensive unit test suite (v1.4.0)
- Add 38 unit tests covering fw_client, recipes, lists, and server modules
- Test error handling: top-level ex/un, nested a00.un.un/a00.ex.ex, successful responses
- Test ingredients_parsed parser: newlines, empty lines, commas, headings
- Test list name translation: SYS-CAT-SHOPPINGLIST, SYS-CAT-TODOS, unknown names
- Test date validation: ISO YYYY-MM-DD format enforcement
- Test error response helper: JSON format, Unicode, special characters
- All tests use unittest.mock (stdlib), no external mocking libraries
- No API calls — all HTTP interactions mocked
- Framework: pytest with asyncio support
- Run: uv run pytest tests/test_unit.py -v

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 23:39:17 +02:00
marcus 35cbfd3061 fix(wall-posts): add delete_wall_post and fix add_comment response parser (v1.3.1)
- Fix add_comment response parser: corrected to a00.r.r.comment.commentId structure
- Fix add_comment error handling: return errors directly instead of warnings
- Add delete_wall_post: permanently delete wall posts via metadelete endpoint
- Add delete_wall_post to README.md Wall & Activities section
- Update SPEC.md with correct walladdComment response structure
- Update SPEC.md metadelete to include wall posts as supported type
- Update CHANGELOG.md with v1.3.1 bugfixes and additions
- Version bumped to 1.3.1 in pyproject.toml

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 23:25:45 +02:00
marcus 0e7c4da362 feat(wall-posts): add wall post reading/writing with comments (v1.3.0)
- Add get_wall_posts: read recent wall posts with like/comment counts
- Add create_wall_post: publish new status posts to the wall
- Add add_comment: add comments to wall posts and activities
- like_post already supports both wall posts and activities (v1.2.0)
- Update README.md with new Wall & Activities section
- Update CLAUDE.md with v1.3.0 and tool reorganization
- Update CHANGELOG.md with v1.3.0 release notes
- Add wallpublish and walladdComment documentation to SPEC.md

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 23:19:37 +02:00
marcus 70c2f61f05 feat(like_post): add unlike support via remove.0 array dot-notation (v1.2.0)
Unlike is now implemented: add=\$empty, remove.0=STAR (verified via Network Interceptor).
Adds optional mood parameter (default STAR). Removes the early-return error path for like=False.
SPEC.md, CLAUDE.md, CHANGELOG.md updated; Unlike offene Punkte entry removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:57:47 +02:00
marcus 4c1e4e2c23 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
2026-04-17 22:41:16 +02:00
marcus d6d8d40305 fix(tasks): remove non-functional reminder write params (v1.1.2)
Exhaustive FW_DEBUG investigation on both Free and Premium accounts
confirmed that taskupdate2 silently ignores all reminder fields regardless
of encoding (flat, JSON-string, PHP-bracket, all variants). Alternative
endpoints (tasksetalert etc.) are unregistered. Root cause: mobile app
Service Worker transforms reminder requests in ways not reproducible via
direct API calls. Reminders remain read-only. Recurrency write confirmed
working (flat encoding). Docs updated with full investigation findings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:35:23 +02:00
marcus 08ee5fb84a fix(tasks): send recurrency/reminder as flat top-level params (v1.1.1)
FiZ Ai() encoder does not support nested objects — recurrencyDescriptor
and reminder fields must be top-level params (recurrency=WEEKLY, not
recurrencyDescriptor={recurrency:WEEKLY}). Same fix for reminder fields.
SPEC.md and CLAUDE.md updated to document the flat encoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:18:58 +02:00
marcus f5eb0a46c8 feat(tasks): add recurrency and reminder write support to update_task (v1.1.0)
Verified parameters from JS-Bundle xb-Encoder/fc-Encoder now wired up:
recurrencyDescriptor (recurrency, recurrencyInterval, rrule) and reminder
(reminderUnit, reminderValue). Adds clear_recurrency and clear_reminder flags.
SPEC.md, CHANGELOG.md, CLAUDE.md updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:15:05 +02:00
marcus 3e021bf01a chore(v1.0.1): opus review findings — docstring confirmation patterns, pyproject.urls, gitignore fix, logging
**Critical:**
- Add IMPORTANT confirmation lines to toggle_task and like_post docstrings
- Add [project.urls] section with Gitea repository link to pyproject.toml

**Medium:**
- Fix .gitignore: test_*.py recursive pattern (remove leading slash)
- Add exception logging to clear_list() for failed deletions
- Update get_categories() docstring: clarify custom vs system category locale behavior
- Add Versioning Policy section to CHANGELOG.md

**Minor:**
- Fix CLI help text (remove "(read-only)" qualifier)
- Add language note to CLAUDE.md (German file, English code)
- Create tests/README.md with integration test documentation
- Update version to 1.0.1 in pyproject.toml

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-17 17:28:22 +02:00
11 changed files with 896 additions and 97 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"Bash(ruff check *)", "Bash(ruff check *)",
"Bash(uv run *)", "Bash(uv run *)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit -m ' *)" "Bash(git commit -m ' *)",
"Bash(FW_DEBUG=1 uv run mcp-familywall check)"
] ]
} }
} }
+7 -7
View File
@@ -42,11 +42,11 @@ htmlcov/
# uv # uv
uv.lock uv.lock
# Debug and probe scripts (root-level only) # Debug and probe scripts
/debug_*.py debug_*.py
/probe_*.py probe_*.py
/probe_*.txt probe_*.txt
/probe_out.txt probe_out.txt
/p*_err.txt p*_err.txt
/test_*.py test_*.py
reference/ reference/
+208
View File
@@ -5,6 +5,214 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Versioning Policy
This project follows Semantic Versioning (SemVer).
Breaking changes (removed tools, changed parameters) increment the major version.
## [1.4.4] 2026-04-20
### Updated
- **README.md restructuring** — consolidated `get_activities` and `like_post` into Wall & Activities section
- `get_activities` moved from Lists & Tasks to Wall & Activities (belongs with wall functionality)
- `like_post` reference removed from Lists & Tasks (consolidated in one section)
- Version bump in README header: v1.3.1 → v1.4.3
### Notes
- Documentation-only release; no code changes
- All tools remain functional and unchanged
- Improves UX by grouping related wall/activity tools in one section
## [1.4.3] 2026-04-18
### Improved
- **Usability: ID resolution in docstrings** — enforce transparent display of human-readable names:
- `get_tasks`: Document required member/category name resolution before user display
- `get_lists`: Document required circle name resolution before user display
- `get_activities`: Document required author name resolution before user display
- `get_wall_posts`: Document required author name resolution before user display
- `get_members`: Added proactive call guidance before presenting member-ID data
- `get_circles`: Added proactive call guidance before presenting circle-ID data
- `get_categories`: Added proactive call guidance for shopping list tasks
- `get_meal_plan`: Document required recipe name resolution for recipe_id before user display
- `CLAUDE.md`: Added general usability rule at top of implementation section
### Notes
- No breaking changes; docstring enhancements only
- All tools remain backward compatible
- Ensures consistent UX: no raw numeric/metaIDs shown to users in any context
## [1.4.2] 2026-04-18
### Fixed
- **`get_tasks` support for secondary circles** — lists in non-primary circles now return tasks correctly
- Circle is automatically derived from `list_id` format (`taskList/<circleNum>_<listNum>`)
- Scope parameter passed to `accgetallfamily` call; matches `get_lists` pattern
- Docstring updated to clarify circle support and list_id format requirement
### Notes
- `_accgetallfamily()` now accepts optional `scope` parameter for secondary circle support
- No breaking changes; all existing code remains compatible
## [1.4.1] 2026-04-17
### Improved
- **Docstring enhancements** — clearer tool usage guidance for new Claude sessions:
- `get_wall_posts`: Added note that results include automatic activity entries (task updates, list changes)
- `get_activities`: Expanded description and added distinction from `get_wall_posts`
- `like_post`: Extended post_id examples to include `task/` and `taskList/` IDs from activities
- `get_recipe_categories`: Added usage notes (IDs for create/update, 5 free-tier categories, family-wide scope)
- `add_meal_note`: Clarified create-only behavior; to update, delete then recreate
- `clear_list`: Moved risk warning (default deletes all tasks) to opening line for visibility
- `create_recipe`: Verified IMPORTANT confirmation line present
### Notes
- No breaking changes; docstring improvements only
- All tools remain backward compatible
## [1.4.0] 2026-04-17
### Added
- **Unit test suite** (`tests/test_unit.py`) — comprehensive tests for core modules
- `fw_client.py`: Error handling (top-level `ex`/`un`, nested `a00.un.un`/`a00.ex.ex`, successful responses)
- `recipes.py`: `ingredients_parsed` parser (newline handling, empty lines, headings, commas)
- `lists.py`: System list name translation (SYS-CAT-SHOPPINGLIST, SYS-CAT-TODOS, unknown names)
- `server.py`: `_validate_date()` (ISO YYYY-MM-DD validation, format errors)
- `server.py`: `_err()` helper (JSON response format, Unicode, special characters)
- All tests use `unittest.mock` (stdlib, no external dependencies)
- Tests run without API access; all HTTP calls are mocked
- Framework: pytest with asyncio support
### Notes
- Run tests: `uv run pytest tests/test_unit.py -v`
- No breaking changes; release is testing infrastructure only
## [1.3.1] 2026-04-17
### Fixed
- **`add_comment` response parser**: corrected response structure from `a00.r.r` with `metaId`
to nested `a00.r.r.comment.commentId`; response now includes `"created": true` flag
- **`add_comment` error handling**: now returns error messages directly instead of warnings
### Added
- **`delete_wall_post`** — permanently delete a wall post using the `metadelete` endpoint
(identical to deleting tasks and recipes)
### Notes
- All wall post tools now complete CRUD operations (Create, Read, Update via like_post, Delete)
- Test post (wall/16282169_31146617) removed from production after verification
## [1.3.0] 2026-04-17
### Added
- **Wall post reading**: `get_wall_posts` — retrieve recent wall posts with author, text,
creation date, like count, liked-by-me flag, and comment count
- **Wall post writing**: `create_wall_post` — publish a new status post to the wall
- **Comments**: `add_comment` — add a comment to a wall post or activity
- All three new wall post tools require user confirmation before calling
### Notes
- `get_wall_posts` supersedes the activity-listing functionality of `get_activities`
(which continues to exist for backward compatibility)
- `like_post` already supports both wall posts and activities; verified to work
with metaIds from both `get_wall_posts` and `get_activities`
- Post IDs are in the format `wall/<familyid>_<postid>`
## [1.2.0] 2026-04-17
### Added
- **Unlike support** for `like_post`: passing `like=False` now removes a STAR reaction via
`add=$empty, remove.0=STAR` (array dot-notation). New optional `mood` parameter (default `"STAR"`).
- **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
- `update_task`: removed non-functional reminder write parameters (`reminder_unit`,
`reminder_value`, `clear_reminder`) — exhaustive FW_DEBUG testing (flat params,
JSON-string, PHP-bracket, all string values, with/without `localId`, alternative
endpoints) on both Free and Premium accounts confirmed that `taskupdate2` silently
ignores all reminder fields; historical SNOOZE reminders were set via the mobile
app's Service Worker which transforms requests in ways not reproducible via direct API
- `update_task`: docstring now explicitly states reminders are read-only
- SPEC.md: documented reminder write limitation with investigation findings
- CLAUDE.md: updated reminder row to reflect read-only status
### Investigation notes
- Tested formats: flat int params, flat string params, JSON-encoded string value,
PHP-bracket notation (`reminder[reminderUnit]=DAY`), no-`localId` variant
- Tested endpoints: `tasksetalert`, `taskalertput`, `taskreminderset`, `taskalert`,
`tasknotification` — all return `"The call X is not registered"`
- Verified on both test account (Free) and real account (Premium) — same result
- Recurrency write via flat params is confirmed working (separate from reminder)
---
## [1.1.1] 2026-04-17
### Fixed
- `update_task`: recurrency and reminder fields now sent as flat top-level parameters
instead of nested objects — the FiZ `Ai()` encoder does not support nested objects;
`recurrencyDescriptor`/`reminder` keys are never sent, fields go directly to top level
(e.g. `recurrency=WEEKLY`, `reminderUnit=DAY` rather than `recurrencyDescriptor={...}`)
- SPEC.md and CLAUDE.md corrected to document the flat encoding
---
## [1.1.0] 2026-04-17
### Added
- `update_task`: recurrency parameters (`recurrency`, `recurrency_interval`, `rrule`,
`clear_recurrency`) — set or remove task recurrence rules via `recurrencyDescriptor`
- `update_task`: reminder parameters (`reminder_unit`, `reminder_value`, `clear_reminder`)
— set or remove task reminders via `reminder`
- SPEC.md: documented `recurrencyDescriptor` and `reminder` fields for `taskupdate2`
(verified via JS-Bundle xb-Encoder / fc-Encoder)
---
## [1.0.1] 2026-04-17
### Added
- IMPORTANT confirmation notes in docstrings for write operations (`toggle_task`, `like_post`)
- `[project.urls]` section in `pyproject.toml` with Gitea repository link
- Language note in CLAUDE.md explaining German/English split
- `tests/README.md` documentation for running integration tests
### Changed
- `.gitignore` patterns now recursive (test_*.py without leading slash)
- CLI help text updated (removed "(read-only)" qualifier)
- `clear_list()` now logs exception reasons for failed deletions
- `get_categories()` docstring clarifies custom vs. system category locale behavior
### Fixed
- Exception handling in `clear_list()` with detailed logging
---
## [1.0.0] 2026-04-17 ## [1.0.0] 2026-04-17
### Added ### Added
+18 -7
View File
@@ -1,5 +1,8 @@
# mcp-familywall # mcp-familywall
**Note:** This file is intentionally written in German (developer preference).
Code, docstrings, and commit messages are in English.
## Kontext ## Kontext
Dieses Projekt entwickelt `mcp-familywall` einen MCP-Server für den Dieses Projekt entwickelt `mcp-familywall` einen MCP-Server für den
@@ -24,20 +27,21 @@ und wird in Claude Desktop eingebunden.
## Aktueller Stand ## Aktueller Stand
### Version: **v1.0.0** ← aktuell ### Version: **v1.4.3** ← aktuell
### Implementierte Tools ### Implementierte Tools
| Kategorie | Tools | | Kategorie | Tools |
|---|---| |---|---|
| Wall & Aktivitäten | `get_wall_posts`, `create_wall_post`, `add_comment`, `like_post` |
| Kreise & Mitglieder | `get_circles`, `get_members`, `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` | | Kreise & Mitglieder | `get_circles`, `get_members`, `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` |
| Listen & Tasks | `get_lists`, `get_tasks`, `get_categories`, `get_activities`, `create_list`, `update_list`, `delete_list`, `create_category`, `delete_category`, `create_task`, `update_task`, `toggle_task`, `delete_task`, `clear_list`, `like_post` | | Listen & Tasks | `get_lists`, `get_tasks`, `get_categories`, `get_activities`, `create_list`, `update_list`, `delete_list`, `create_category`, `delete_category`, `create_task`, `update_task`, `toggle_task`, `delete_task`, `clear_list` |
| Rezeptbox | `get_recipe_categories`, `get_recipe_box`, `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe` | | Rezeptbox | `get_recipe_categories`, `get_recipe_box`, `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe` |
| Essensplaner | `get_meal_plan`, `add_recipe_to_meal_plan`, `add_meal_to_meal_plan`, `add_meal_note`, `delete_meal_plan_entry` | | Essensplaner | `get_meal_plan`, `add_recipe_to_meal_plan`, `add_meal_to_meal_plan`, `add_meal_note`, `delete_meal_plan_entry` |
### Roadmap (Nächstes) ### Roadmap (Nächstes)
- v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren) - v2.0: Weitere Wall-Post Features (Edits, Deletes, Emoji-Reactions beyond STAR)
### Historische Meilensteine (kompakt) ### Historische Meilensteine (kompakt)
@@ -47,6 +51,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
@@ -91,6 +97,9 @@ können den echten Request-Body in diesen Fällen nicht sehen.
## Claude Code Implementierungsregeln ## Claude Code Implementierungsregeln
**Usability rule:** Never present raw API IDs (numeric IDs, metaIds, category IDs, circle IDs, member IDs) to the user.
Always resolve them to human-readable names before responding.
- **Feature complete before next feature** jedes Feature vollständig - **Feature complete before next feature** jedes Feature vollständig
implementieren, testen und verifizieren bevor das nächste beginnt implementieren, testen und verifizieren bevor das nächste beginnt
- **Kein destruktives Probing** keine Probe-Calls auf System-Kategorien, - **Kein destruktives Probing** keine Probe-Calls auf System-Kategorien,
@@ -129,9 +138,12 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level:
| `taskcreate2` | `taskListId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee` | | | `taskcreate2` | `taskListId`, `text`, `description`, `taskCategoryId`, `dueDate`, `assignee` | |
| `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` | 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"` | | `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`, `add`, `remove`/`remove.0` | Like: `add="STAR", remove="$empty"`; Unlike: `add="$empty", remove.0="STAR"` (Array-Dot-Notation) |
| `taskcategoryput` | `name`, `emoji` | | | `taskcategoryput` | `name`, `emoji` | |
| `taskcategorydelete` | `id` | metaId der Kategorie | | `taskcategorydelete` | `id` | metaId der Kategorie |
| `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji`, `scope` | `taskListType`: `SHOPPING_LIST`, `TODOS`, `OTHER`; `scope`: Kreis-metaId für nicht-primäre Kreise | | `taskcreatelist` | `name`, `taskListType`, `sharedToAll`, `color`, `emoji`, `scope` | `taskListType`: `SHOPPING_LIST`, `TODOS`, `OTHER`; `scope`: Kreis-metaId für nicht-primäre Kreise |
@@ -150,9 +162,8 @@ Fehler bei falschen Parametern kommen nicht immer auf Top-Level:
### Self-Like-Restriction ### Self-Like-Restriction
Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts. Eigene Posts können nicht geliked werden. API antwortet 200, macht aber nichts.
### Unlike nicht möglich ### Unlike
Service Worker verschlüsselt den Unlike-Request-Body. Unlike via `remove.0=STAR` (Array-Dot-Notation). Verifiziert 2026-04-17 via Network-Interceptor.
Endpoint unbekannt. `like_post(like=False)` gibt Fehlermeldung zurück.
### mpstar / Rezept-Favorit ### mpstar / Rezept-Favorit
Service Worker fängt `mpstar` ab. `metamood` funktioniert nur auf Service Worker fängt `mpstar` ab. `metamood` funktioniert nur auf
+14 -5
View File
@@ -2,7 +2,18 @@
MCP server for [Family Wall](https://www.familywall.com) — manage your family's circles, lists, tasks, recipes, and meal plan directly from Claude. MCP server for [Family Wall](https://www.familywall.com) — manage your family's circles, lists, tasks, recipes, and meal plan directly from Claude.
## Tools (v1.0.0) ## Tools (v1.4.4)
### Wall & Activities
| Tool | Description |
|---|---|
| `get_wall_posts` | Get recent wall posts (text, author, likes, comments) |
| `get_activities` | List recent activities (task updates, list changes) |
| `create_wall_post` 🔒 | Create a new status post on the wall |
| `add_comment` 🔒 | Add a comment to a post |
| `like_post` 🔒 | Like or unlike a wall post/activity |
| `delete_wall_post` 🔒 | Permanently delete a wall post |
### Circles & Members ### Circles & Members
@@ -22,18 +33,16 @@ MCP server for [Family Wall](https://www.familywall.com) — manage your family'
| `get_lists` | List all task lists (emoji, color, circle; optional `scope` filter) | | `get_lists` | List all task lists (emoji, color, circle; optional `scope` filter) |
| `get_tasks` | List tasks in a list (category, due date, assignees, recurrency, reminder) | | `get_tasks` | List tasks in a list (category, due date, assignees, recurrency, reminder) |
| `get_categories` | List categories for a list (locale-filtered; custom categories always shown) | | `get_categories` | List categories for a list (locale-filtered; custom categories always shown) |
| `get_activities` | List recent wall activities (author resolved to display name) |
| `create_list` 🔒 | Create a task list (`SHOPPING_LIST`, `TODOS`, or `OTHER`; optional emoji, color, circle) | | `create_list` 🔒 | Create a task list (`SHOPPING_LIST`, `TODOS`, or `OTHER`; optional emoji, color, circle) |
| `update_list` 🔒 | Rename a list or change its emoji/color (partial update; system lists protected) | | `update_list` 🔒 | Rename a list or change its emoji/color (partial update; system lists protected) |
| `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) |
| `like_post` 🔒 | Like a wall post/activity |
### Recipe Box ### Recipe Box
+92 -7
View File
@@ -201,9 +201,41 @@ POST https://api.familywall.com/api/taskupdate2
| `dueDate` | nein | ISO 8601 oder `$empty` zum Löschen | | `dueDate` | nein | ISO 8601 oder `$empty` zum Löschen |
| `assignee` | nein | Member-accountId (mehrfach sendbar), `""` zum Entfernen aller | | `assignee` | nein | Member-accountId (mehrfach sendbar), `""` zum Entfernen aller |
| `taskListId` | nein | neue Listen-metaId (verschiebt Task) | | `taskListId` | nein | neue Listen-metaId (verschiebt Task) |
| `recurrency` | nein | `"DAILY"` \| `"WEEKLY"` \| `"MONTHLY"` \| `"YEARLY"` \| `"NONE"` (entfernen) |
| `recurrencyInterval` | nein | int (z.B. `2` für "alle 2 Wochen") |
| `rrule` | nein | vollständige iCal-RRULE (z.B. `"FREQ=WEEKLY;INTERVAL=2;BYDAY=FR"`) |
| `byDay` | nein | z.B. `"FR"`, `"1SA"`, `"MO,TU,WE,TH,FR"` |
| `byMonthDay` | nein | int (z.B. `7` für "am 7. des Monats") |
| `recurrencyEndDate` | nein | ISO-Datum (z.B. `"2026-12-31"`) |
| `endOccurence` | nein | int (nach N Wiederholungen aufhören) |
**Hinweis:** `taskListId` ist NICHT Pflicht beim Update. **Hinweis:** `taskListId` ist NICHT Pflicht beim Update.
**⚠️ 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 (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:** **Response:**
``` ```
a00.r.r → vollständiges Task-Objekt a00.r.r → vollständiges Task-Objekt
@@ -237,32 +269,86 @@ POST https://api.familywall.com/api/metadelete
- Tasks: `task/<id>` - Tasks: `task/<id>`
- Rezepte: `recipe/<id>` - Rezepte: `recipe/<id>`
- Essensplan-Einträge: `dish/<id>` und `meal/<id>` - Essensplan-Einträge: `dish/<id>` und `meal/<id>`
- Wall-Posts: `wall/<id>` (v1.3.1+)
**Response:** **Response:**
``` ```
a00.r.r → "true" (String) a00.r.r → "true" (String)
``` ```
### `wallmood` Post liken ### `wallmood` Post liken / unlike
POST https://api.familywall.com/api/wallmood POST https://api.familywall.com/api/wallmood
**Body-Parameter:** **Body-Parameter (Like):**
| Parameter | Wert | | Parameter | Wert |
|---|---| |---|---|
| `wall_message_id` | Post-metaId ⚠️ nicht `wallId` oder `id`! | | `wall_message_id` | Post-metaId ⚠️ nicht `wallId` oder `id`! |
| `moodType` | `"STAR"` für Like | | `add` | `"STAR"` (Mood-Typ setzen) |
| `remove` | `"$empty"` (Sentinel für „nichts entfernen") |
**Body-Parameter (Unlike):**
| Parameter | Wert |
|---|---|
| `wall_message_id` | Post-metaId |
| `add` | `"$empty"` (Sentinel für „nichts hinzufügen") |
| `remove.0` | `"STAR"` (Array-Dot-Notation — `remove` ist ein Array) |
**Bekannte Einschränkungen:** **Bekannte Einschränkungen:**
- Unlike: Endpoint/Parameter unbekannt (Service Worker verschlüsselt Request-Body)
- Self-Like: API antwortet 200, macht aber serverseitig nichts - Self-Like: API antwortet 200, macht aber serverseitig nichts
- `moodType="NONE"` und andere Werte haben keine Wirkung
**Response:** **Response:**
``` ```
a00.r.r → Wall-Objekt mit moodMap, refAction: "MOOD_STAR" a00.r.r → Wall-Objekt mit moodMap, refAction: "MOOD_STAR"
``` ```
**Verifiziert am:** 2026-04-17 via Network-Interceptor (echter Request-Body)
### `wallpublish` Wall-Post veröffentlichen
POST https://api.familywall.com/api/wallpublish
**Body-Parameter:**
| Parameter | Wert |
|---|---|
| `tagline` | Post-Text |
**Response:**
```
a00.r.r → Wall-Post-Objekt
.metaId → neue Post-ID
.tagline → Post-Text
.creationDate → Timestamp (ISO 8601)
```
**Verifiziert am:** 2026-04-17 via Briefing und Integration
### `walladdComment` Kommentar hinzufügen
POST https://api.familywall.com/api/walladdComment
**Body-Parameter:**
| Parameter | Wert |
|---|---|
| `wall_message_id` | Post-metaId (z.B. `wall/23431854_31119189`) |
| `comment` | Kommentartext |
**Response:**
```
a00.r.r.comment → Kommentar-Objekt (nested)
.commentId → neue Kommentar-ID
.text → Kommentartext
.creationDate → Timestamp (ISO 8601)
```
**Bekannte Einschränkungen:**
- `mood` und `clientOpId` sind optional und werden ignoriert
**Verifiziert am:** 2026-04-17 via Briefing und Integration (Response-Struktur in v1.3.1 korrekt dokumentiert)
### `taskcategoryput` Kategorie erstellen/aktualisieren ### `taskcategoryput` Kategorie erstellen/aktualisieren
POST https://api.familywall.com/api/taskcategoryput POST https://api.familywall.com/api/taskcategoryput
@@ -917,7 +1003,6 @@ bevor er den Server erreicht. Die Transformation ist ohne Service-Worker-Analyse
## Offene Punkte ## Offene Punkte
- Unlike-Endpoint (Service Worker blockiert Analyse)
- `mpstar` / `isFavorite` für Rezepte (Service Worker blockiert Analyse, siehe oben) - `mpstar` / `isFavorite` für Rezepte (Service Worker blockiert Analyse, siehe oben)
- Erinnerungen (reminder) nur Premium-Account - Erinnerungen (reminder) nur Premium-Account
- Wiederholungen (repeat) nur Premium-Account - Wiederholungen (repeat) nur Premium-Account
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcp-familywall" name = "mcp-familywall"
version = "1.0.0" version = "1.4.4"
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"
@@ -21,6 +21,9 @@ dependencies = [
"rich>=13.0", "rich>=13.0",
] ]
[project.urls]
Repository = "https://gitea.gecheckt.de/marcus/mcp-familywall"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
+12 -15
View File
@@ -14,7 +14,6 @@ Prerequisites: credentials stored in OS keyring or FW_EMAIL / FW_PASSWORD set.
from __future__ import annotations from __future__ import annotations
import json
import os import os
import sys import sys
@@ -39,24 +38,22 @@ FORCED_CATEGORY_META_ID: str | None = None
CANDIDATES: list[dict[str, str | None]] = [ CANDIDATES: list[dict[str, str | None]] = [
# key → value format description # key → value format description
# Full metaId variants # Full metaId variants
{"key": "systemCategoryId", "fmt": "numeric"}, # just the number, e.g. "200" {"key": "systemCategoryId", "fmt": "numeric"}, # just the number, e.g. "200"
{"key": "taskCategorySystemId", "fmt": "numeric"}, {"key": "taskCategorySystemId", "fmt": "numeric"},
{"key": "categories", "fmt": "meta_id"}, # full taskCategory/… metaId {"key": "categories", "fmt": "meta_id"}, # full taskCategory/… metaId
{"key": "categoryIds", "fmt": "meta_id"}, {"key": "categoryIds", "fmt": "meta_id"},
{"key": "taskCategoryName", "fmt": "name"}, # category name as string {"key": "taskCategoryName", "fmt": "name"}, # category name as string
{"key": "categoryName", "fmt": "name"}, {"key": "categoryName", "fmt": "name"},
{"key": "taskCategoryId", "fmt": "meta_id"}, # already tried but try numeric too {"key": "taskCategoryId", "fmt": "meta_id"}, # already tried but try numeric too
# Numeric variants of already-tried names # Numeric variants of already-tried names
{"key": "taskCategoryId", "fmt": "numeric"}, {"key": "taskCategoryId", "fmt": "numeric"},
{"key": "categoryId", "fmt": "numeric"}, {"key": "categoryId", "fmt": "numeric"},
{"key": "category", "fmt": "numeric"}, {"key": "category", "fmt": "numeric"},
{"key": "categoryMetaId", "fmt": "numeric"}, {"key": "categoryMetaId", "fmt": "numeric"},
] ]
def _resolve_category( def _resolve_category(client: FamilyWallClient, list_id: str) -> tuple[str, str, str]:
client: FamilyWallClient, list_id: str
) -> tuple[str, str, str]:
"""Return (meta_id, system_category_id_str, name) for the first German category.""" """Return (meta_id, system_category_id_str, name) for the first German category."""
data = client.call( data = client.call(
"accgetallfamily", "accgetallfamily",
+1 -1
View File
@@ -21,7 +21,7 @@ from mcp_familywall import __version__
@click.version_option(__version__, "-v", "--version", prog_name="mcp-familywall") @click.version_option(__version__, "-v", "--version", prog_name="mcp-familywall")
@click.pass_context @click.pass_context
def app(ctx: click.Context) -> None: def app(ctx: click.Context) -> None:
"""mcp-familywall — MCP server for Family Wall (read-only).""" """mcp-familywall — MCP server for Family Wall."""
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
click.echo(ctx.get_help()) click.echo(ctx.get_help())
+499 -53
View File
@@ -44,9 +44,13 @@ def _validate_date(date: str) -> str | None:
return None return None
def _accgetallfamily() -> dict[str, Any]: def _accgetallfamily(scope: str | None = None) -> dict[str, Any]:
"""Login, call accgetallfamily, logout and return the response body. """Login, call accgetallfamily, logout and return the response body.
Args:
scope: Optional circle metaId (e.g. "family/16473836") to fetch data for
a specific circle. When None (default), fetches data for the primary circle.
Raises: Raises:
RuntimeError: On credential or API errors. RuntimeError: On credential or API errors.
""" """
@@ -58,10 +62,10 @@ def _accgetallfamily() -> dict[str, Any]:
try: try:
with FamilyWallClient() as client: with FamilyWallClient() as client:
client.login(email, password) client.login(email, password)
data = client.call( params: dict[str, Any] = {"a01call": "taskcategorysync", "a02call": "tasksync"}
"accgetallfamily", if scope:
{"a01call": "taskcategorysync", "a02call": "tasksync"}, params["scope"] = scope
) data = client.call("accgetallfamily", params)
client.logout() client.logout()
return data return data
except FamilyWallError as exc: except FamilyWallError as exc:
@@ -142,7 +146,10 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
@mcp.tool() @mcp.tool()
def get_circles() -> str: def get_circles() -> str:
"""Return all Family Wall circles as JSON list of {id, name}.""" """Return all Family Wall circles as JSON list of {id, name}.
Call this proactively before presenting any data that contains circle IDs to the user.
"""
try: try:
raw_circles = _famlistfamily() raw_circles = _famlistfamily()
except RuntimeError as exc: except RuntimeError as exc:
@@ -216,6 +223,9 @@ def _famlistfamily() -> list[dict[str, Any]]:
def get_members(circle_id: str | None = None) -> str: def get_members(circle_id: str | None = None) -> str:
"""Return Family Wall circle members as JSON, optionally filtered by circle. """Return Family Wall circle members as JSON, optionally filtered by circle.
Call this proactively before presenting any data that contains member IDs
(assignee_ids, author_id, creator) to the user.
Args: Args:
circle_id: Optional circle ID from get_circles (e.g. ``family/23431854``). circle_id: Optional circle ID from get_circles (e.g. ``family/23431854``).
When omitted all members across all circles are returned. When omitted all members across all circles are returned.
@@ -307,6 +317,9 @@ def get_lists(scope: str | None = None) -> str:
Returns: Returns:
JSON list of list objects with keys id, name, type, open, total, JSON list of list objects with keys id, name, type, open, total,
emoji, color, circle_id. emoji, color, circle_id.
IMPORTANT: When presenting lists to the user, always resolve
circle_id to the circle name via get_circles. Never show raw IDs.
""" """
try: try:
email, password = get_credentials() email, password = get_credentials()
@@ -402,9 +415,35 @@ def get_lists(scope: str | None = None) -> str:
@mcp.tool() @mcp.tool()
def get_tasks(list_id: str, only_open: bool = True) -> str: def get_tasks(list_id: str, only_open: bool = True) -> str:
"""Return tasks for a list as JSON. list_id from get_lists. only_open=True filters completed.""" """Return tasks for a list as JSON.
Lists from all circles are supported. The circle is automatically derived from
the list_id — always use IDs from get_lists, never construct them manually.
Args:
list_id: List ID from get_lists (e.g. ``taskList/23431854_29740942``).
only_open: When True (default), filter to incomplete tasks only.
Returns:
JSON array of task objects with keys: id, text, description, completed,
category_id, due_date, assignee_ids, recurrency, recurrency_interval,
rrule, reminder.
IMPORTANT: Before presenting results to the user, always resolve:
- assignee_ids → member names via get_members
- category_id → category name via get_categories
- circle context → circle name from get_lists
Never show raw IDs to the user.
"""
# Extract circle number from list_id format: taskList/<circleNum>_<listNum>
try: try:
data = _accgetallfamily() circle_num = list_id.split("/")[1].split("_")[0]
scope = f"family/{circle_num}"
except (IndexError, ValueError):
return _err(f"Invalid list_id format: {list_id!r}")
try:
data = _accgetallfamily(scope)
except RuntimeError as exc: except RuntimeError as exc:
return _err(str(exc)) return _err(str(exc))
@@ -510,12 +549,16 @@ def get_categories(list_id: str, locale: str = "de") -> str:
"""Return the task categories available for a list as JSON. """Return the task categories available for a list as JSON.
Only shopping lists (taskListType=SHOPPING_LIST) have categories. TODO Only shopping lists (taskListType=SHOPPING_LIST) have categories. TODO
lists return an empty list. Categories are filtered by locale so only lists return an empty list. System categories are filtered by locale so only
the language-appropriate names are returned (default: German). the language-appropriate names are returned (default: German). Custom categories
have no locale field and are always displayed regardless of the locale parameter.
Use the returned ``id`` values as the ``category_id`` parameter in Use the returned ``id`` values as the ``category_id`` parameter in
``create_task`` and ``update_task``. ``create_task`` and ``update_task``.
Call this proactively before presenting shopping list tasks to the user so
category_id values can be shown as readable names.
Args: Args:
list_id: List ID from get_lists (e.g. ``taskList/23431854_29740942``). list_id: List ID from get_lists (e.g. ``taskList/23431854_29740942``).
locale: BCP-47 language code for category names (default ``"de"``). locale: BCP-47 language code for category names (default ``"de"``).
@@ -736,7 +779,21 @@ def delete_category(category_id: str) -> str:
@mcp.tool() @mcp.tool()
def get_activities(limit: int = 20) -> str: def get_activities(limit: int = 20) -> str:
"""Return recent Family Wall wall activities as JSON. limit controls max number of results.""" """Return recent Family Wall activity events as JSON.
Gives task and list activities (task creation/updates, list changes).
For wall posts (status updates, photos, comments), use get_wall_posts.
Args:
limit: Maximum number of activities to return (default 20).
Returns:
JSON list of activity objects sorted by date descending,
including wall, task, and taskList activity entries.
IMPORTANT: Before presenting to the user, resolve author_id to
the member name via get_members. Never show raw IDs.
"""
try: try:
email, password = get_credentials() email, password = get_credentials()
except RuntimeError as exc: except RuntimeError as exc:
@@ -806,6 +863,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.
@@ -846,6 +956,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.
@@ -877,10 +989,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
@@ -890,6 +1012,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)
@@ -925,6 +1048,13 @@ def update_task(
clear_due_date: bool = False, clear_due_date: bool = False,
assignee_ids: list[str] | None = None, assignee_ids: list[str] | None = None,
list_id: str | None = None, list_id: str | None = None,
recurrency: str | None = None,
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: ) -> str:
"""Update an existing task's fields. """Update an existing task's fields.
@@ -947,12 +1077,39 @@ def update_task(
list_id: Move the task to a different list by providing the target list ID list_id: Move the task to a different list by providing the target list ID
from get_lists (e.g. ``"taskList/23431854_29740942"``). Omit to keep in from get_lists (e.g. ``"taskList/23431854_29740942"``). Omit to keep in
current list. current list.
recurrency: Recurrence frequency — ``"DAILY"``, ``"WEEKLY"``, ``"MONTHLY"``,
``"YEARLY"``, or ``"NONE"``. Examples: daily → ``recurrency="DAILY",
recurrency_interval=1``; every 2 weeks on Friday →
``recurrency="WEEKLY", recurrency_interval=2,
rrule="FREQ=WEEKLY;INTERVAL=2;BYDAY=FR"``.
Cannot be used together with *clear_recurrency*.
recurrency_interval: Repeat every N units (e.g. ``2`` for "every 2 weeks").
Only meaningful when *recurrency* is set.
rrule: Full iCalendar RRULE string (e.g. ``"FREQ=WEEKLY;INTERVAL=2;BYDAY=FR"``).
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: Returns:
JSON success indicator or an error message. JSON success indicator or an error message.
""" """
if clear_due_date and due_date is not None: if clear_due_date and due_date is not None:
return _err("'clear_due_date' and 'due_date' cannot be used together.") return _err("'clear_due_date' and 'due_date' cannot be used together.")
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 ( if (
text is None text is None
@@ -962,10 +1119,15 @@ def update_task(
and not clear_due_date and not clear_due_date
and assignee_ids is None and assignee_ids is None
and list_id is None and list_id is None
and recurrency is None
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', or 'list_id' 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} params: dict[str, Any] = {"metaId": task_id}
@@ -985,6 +1147,19 @@ def update_task(
if list_id is not None: if list_id is not None:
params["taskListId"] = list_id params["taskListId"] = list_id
# Recurrency fields are sent flat at the top level (verified: FiZ Ai() encoder).
if clear_recurrency:
params["recurrency"] = "NONE"
elif recurrency is not None:
params["recurrency"] = recurrency
if recurrency_interval is not None:
params["recurrencyInterval"] = recurrency_interval
if rrule is not None:
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:
@@ -1003,6 +1178,8 @@ def update_task(
def toggle_task(task_id: str, complete: bool) -> str: def toggle_task(task_id: str, complete: bool) -> str:
"""Mark a task as complete or incomplete. """Mark a task as complete or incomplete.
IMPORTANT: Ask the user for confirmation before calling this tool.
Args: Args:
task_id: Task metaId from get_tasks. task_id: Task metaId from get_tasks.
complete: ``True`` to mark done, ``False`` to reopen. complete: ``True`` to mark done, ``False`` to reopen.
@@ -1061,7 +1238,10 @@ def delete_task(task_id: str) -> str:
@mcp.tool() @mcp.tool()
def clear_list(list_id: str, only_open: bool = False) -> str: def clear_list(list_id: str, only_open: bool = False) -> str:
"""Delete all tasks in a list within a single authenticated session. """Delete all tasks in a list (or only open tasks).
⚠️ **Warning**: By default (only_open=False) this deletes all tasks
including completed ones. Set only_open=True to keep completed tasks.
IMPORTANT: Ask the user for confirmation before calling this tool. IMPORTANT: Ask the user for confirmation before calling this tool.
@@ -1072,7 +1252,7 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
list_id: List metaId from get_lists list_id: List metaId from get_lists
(e.g. ``"taskList/16282169_29775360"``). (e.g. ``"taskList/16282169_29775360"``).
only_open: When ``True`` only incomplete tasks are deleted; only_open: When ``True`` only incomplete tasks are deleted;
completed tasks are kept. Default ``False`` deletes all. completed tasks are kept. Default ``False`` deletes all tasks.
Returns: Returns:
JSON with ``deleted_count`` and ``list_id`` on success, JSON with ``deleted_count`` and ``list_id`` on success,
@@ -1110,8 +1290,9 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
try: try:
client.call("metadelete", {"id": meta_id}) client.call("metadelete", {"id": meta_id})
deleted_ids.append(meta_id) deleted_ids.append(meta_id)
except Exception: except Exception as e:
failed_ids.append(meta_id) failed_ids.append(meta_id)
logger.warning(f"Failed to delete {meta_id}: {e}")
client.logout() client.logout()
except FamilyWallError as exc: except FamilyWallError as exc:
@@ -1779,41 +1960,36 @@ def delete_circle(circle_id: str) -> str:
@mcp.tool() @mcp.tool()
def like_post(post_id: str, like: bool = True) -> str: def like_post(post_id: str, like: bool = True, mood: str = "STAR") -> str:
"""Like a wall post/activity with a STAR mood. """Like or unlike a wall post/activity.
Note: Unlike (like=False) is not yet supported. The Family Wall API offers IMPORTANT: Ask the user for confirmation before calling this tool.
no discoverable endpoint or parameter to remove a like. Passing like=False
returns an error without making any API call.
Args: Args:
post_id: Wall post ID from get_activities (e.g. ``wall/23431854_31119189``). post_id: Post metaId from get_wall_posts or get_activities
like: Must be ``True``. ``False`` is reserved for future unlike support. (e.g. ``wall/16282169_31119189`` for wall posts,
``task/16282169_441781324`` for task activities,
``taskList/16282169_123`` for list activities).
like: ``True`` to add a like (default), ``False`` to remove it.
mood: Mood type to set or remove. Default is ``"STAR"``.
Returns: Returns:
JSON success indicator or an error message. JSON with the resulting like state or an error message.
""" """
# Unlike is not yet supported: extensive FW_DEBUG=1 testing showed that # Like: add=STAR, remove=$empty (idempotent set)
# wallmood with moodType="STAR" is an idempotent SET operation (not a toggle). # Unlike: add=$empty, remove.0=STAR (array dot-notation, verified via Network Interceptor)
# Tested and ruled out: moodType variations ("NONE", "REMOVE", "DELETE", ""), if like:
# moodStarShortcut parameter, and alternative endpoints (all return 502). params: dict[str, Any] = {
# See SPEC.md for full investigation notes. "wall_message_id": post_id,
if not like: "add": mood,
return json.dumps( "remove": "$empty",
{"error": "Unlike is not yet supported. The unlike mechanism is unknown."}, }
ensure_ascii=False, else:
indent=2, params = {
) "wall_message_id": post_id,
"add": "$empty",
# Verified via FW_DEBUG=1: "remove.0": mood,
# - Parameter 'wall_message_id': post ID as returned by get_activities }
# - Parameter 'moodType': "STAR" (Family Wall's internal like type; "LIKE" is silently
# mapped to "STAR" server-side — use "STAR" directly)
# - Response a00.r.r: full wall message object with moodMap showing resulting state
params: dict[str, Any] = {
"wall_message_id": post_id,
"moodType": "STAR",
}
try: try:
data = _authenticated_call("wallmood", params) data = _authenticated_call("wallmood", params)
@@ -1844,19 +2020,281 @@ def like_post(post_id: str, like: bool = True) -> str:
result: dict[str, Any] = {"liked": now_liked, "id": post_id, "author": account_id} result: dict[str, Any] = {"liked": now_liked, "id": post_id, "author": account_id}
# Surface a warning when the like call apparently had no effect, so the if now_liked != like:
# caller can distinguish a successful like from a silent API rejection if like:
# (e.g. rate limit, unsupported post type, or self-like restriction). result["warning"] = (
if not now_liked: "Like may not have been applied. "
result["warning"] = ( "Possible causes: rate limit, unsupported post type (e.g. FAMILY_CREATED), "
"Like may not have been applied. " "or self-like restriction."
"Possible causes: rate limit, unsupported post type (e.g. FAMILY_CREATED), " )
"or self-like restriction." else:
result["warning"] = (
"Unlike may not have been applied. "
"Possible causes: post was not liked, rate limit, or API restriction."
)
return json.dumps(result, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Tool: get_wall_posts
# ---------------------------------------------------------------------------
@mcp.tool()
def get_wall_posts(limit: int = 20) -> str:
"""Return recent Family Wall posts as JSON.
Returns posts from the primary circle's wall, sorted by date descending.
Includes status posts, activity entries (task updates, list changes),
comments, and like information. Use get_activities for task-focused activity lists.
Args:
limit: Maximum number of posts to return (default 20).
Returns:
JSON list of post objects with keys:
id, type, text, date, author, author_id,
liked_by_me, like_count, comment_count.
IMPORTANT: Before presenting to the user, resolve author_id to
the member name via get_members. Never show raw IDs.
"""
try:
email, password = get_credentials()
except RuntimeError as exc:
return _err(str(exc))
author_map: dict[str, str] = {}
try:
for circle in _famlistfamily():
for member in circle.get("members") or []:
acc_id: str = member.get("accountId", "")
display = member.get("firstName") or member.get("name") or acc_id
if acc_id:
author_map[acc_id] = display
except RuntimeError:
pass
try:
with FamilyWallClient() as client:
client.login(email, password)
data = client.call("wallget", {"nb": str(limit)})
client.logout()
except FamilyWallError as exc:
return _err(str(exc))
except Exception as exc:
return _err(f"Connection error: {exc}")
raw_posts: list[dict[str, Any]] | None = None
try:
candidate = data["a00"]["r"]["r"]
if isinstance(candidate, list):
raw_posts = candidate
elif isinstance(candidate, dict) and isinstance(candidate.get("updatedCreated"), list):
raw_posts = candidate["updatedCreated"]
except (KeyError, TypeError):
pass
if raw_posts is None:
return json.dumps(
{"warning": "Unexpected wallget response structure", "raw": data},
ensure_ascii=False,
indent=2,
)
result = []
for item in raw_posts:
raw_author: str = item.get("accountId", "")
mood_map: dict[str, Any] = item.get("moodMap") or {}
liked_by_me = item.get("moodStarShortcut") == "true" or any(
"STAR" in moods for moods in mood_map.values()
)
like_count = sum(len(moods) for moods in mood_map.values() if isinstance(moods, list))
comments: list[dict[str, Any]] = item.get("comments") or []
comment_count = len(comments)
result.append(
{
"id": item.get("metaId"),
"type": item.get("refType"),
"text": item.get("text") or item.get("tagline"),
"date": item.get("creationDate"),
"author": author_map.get(raw_author, raw_author),
"author_id": raw_author,
"liked_by_me": liked_by_me,
"like_count": like_count,
"comment_count": comment_count,
}
) )
return json.dumps(result, ensure_ascii=False, indent=2) return json.dumps(result, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Tool: create_wall_post
# ---------------------------------------------------------------------------
@mcp.tool()
def create_wall_post(text: str) -> str:
"""Create a new status post on the Family Wall.
IMPORTANT: Ask the user for confirmation before calling this tool.
Args:
text: Text content of the post.
Returns:
JSON with the new post's metaId on success, or an error message.
"""
try:
email, password = get_credentials()
except RuntimeError as exc:
return _err(str(exc))
try:
with FamilyWallClient() as client:
client.login(email, password)
data = client.call("wallpublish", {"tagline": text})
client.logout()
except FamilyWallError as exc:
return _err(str(exc))
except Exception as exc:
return _err(f"Connection error: {exc}")
try:
post = data["a00"]["r"]["r"]
if not isinstance(post, dict) or "metaId" not in post:
raise TypeError("unexpected shape")
except (KeyError, TypeError):
return json.dumps(
{"warning": "Unexpected wallpublish response structure", "raw": data},
ensure_ascii=False,
indent=2,
)
return json.dumps(
{
"id": post.get("metaId"),
"text": post.get("tagline") or post.get("text"),
"date": post.get("creationDate"),
},
ensure_ascii=False,
indent=2,
)
# ---------------------------------------------------------------------------
# Tool: add_comment
# ---------------------------------------------------------------------------
@mcp.tool()
def add_comment(post_id: str, comment: str) -> str:
"""Add a comment to a wall post or activity.
IMPORTANT: Ask the user for confirmation before calling this tool.
Args:
post_id: Post metaId from get_wall_posts
(e.g. ``"wall/16282169_31119189"``).
comment: Comment text.
Returns:
JSON success indicator or an error message.
"""
try:
email, password = get_credentials()
except RuntimeError as exc:
return _err(str(exc))
try:
with FamilyWallClient() as client:
client.login(email, password)
data = client.call(
"walladdComment",
{
"wall_message_id": post_id,
"comment": comment,
},
)
client.logout()
except FamilyWallError as exc:
return _err(str(exc))
except Exception as exc:
return _err(f"Connection error: {exc}")
try:
response_obj = data["a00"]["r"]["r"]
if not isinstance(response_obj, dict):
raise TypeError("a00.r.r is not a dict")
comment_obj = response_obj.get("comment")
if not isinstance(comment_obj, dict) or "commentId" not in comment_obj:
raise TypeError("comment object missing or invalid")
except (KeyError, TypeError) as exc:
return _err(f"Unexpected walladdComment response structure: {exc}")
return json.dumps(
{
"created": True,
"id": comment_obj.get("commentId"),
"post_id": post_id,
"text": comment_obj.get("text"),
"date": comment_obj.get("creationDate"),
},
ensure_ascii=False,
indent=2,
)
# ---------------------------------------------------------------------------
# Tool: delete_wall_post
# ---------------------------------------------------------------------------
@mcp.tool()
def delete_wall_post(post_id: str) -> str:
"""Permanently delete a wall post.
IMPORTANT: Ask the user for confirmation before calling this tool.
Args:
post_id: Wall post metaId from get_wall_posts
(e.g. ``"wall/16282169_31146617"``).
Returns:
JSON success indicator or an error message.
"""
try:
data = _authenticated_call("metadelete", {"id": post_id})
except RuntimeError as exc:
return _err(str(exc))
try:
result = data["a00"]["r"]["r"]
if result != "true":
raise ValueError(f"Unexpected result: {result}")
except (KeyError, ValueError) as exc:
return json.dumps(
{"warning": "Unexpected metadelete response structure", "error": str(exc), "raw": data},
ensure_ascii=False,
indent=2,
)
return json.dumps(
{
"deleted": True,
"id": post_id,
},
ensure_ascii=False,
indent=2,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper: fetch all raw recipes # Helper: fetch all raw recipes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1908,6 +2346,10 @@ def _get_raw_recipes() -> list[dict[str, Any]]:
def get_recipe_categories() -> str: def get_recipe_categories() -> str:
"""Return all available recipe categories for the family. """Return all available recipe categories for the family.
Use the returned IDs directly in create_recipe/update_recipe via category_ids.
Always includes 5 standard free-tier categories; categories are family-wide,
not recipe-specific.
Returns: Returns:
JSON list of category IDs (e.g. ["category/23431854_2", ...]). JSON list of category IDs (e.g. ["category/23431854_2", ...]).
Always includes the 5 free-tier standard categories if family ID is available. Always includes the 5 free-tier standard categories if family ID is available.
@@ -2358,6 +2800,9 @@ def get_meal_plan(date_from: str, date_to: str) -> str:
- ``can_update`` — whether the entry can be updated - ``can_update`` — whether the entry can be updated
- ``can_delete`` — whether the entry can be deleted - ``can_delete`` — whether the entry can be deleted
IMPORTANT: When recipe_id is present, use get_recipe to resolve it to the
recipe name for display. Never show raw recipe IDs.
Returns an error message string on failure. Returns an error message string on failure.
""" """
if date_err := _validate_date(date_from): if date_err := _validate_date(date_from):
@@ -2637,7 +3082,8 @@ def add_meal_note(
IMPORTANT: Ask the user for confirmation before calling this tool. IMPORTANT: Ask the user for confirmation before calling this tool.
Creates a ``meal/`` entry for the given date and meal type. Creates a new ``meal/`` entry for the given date and meal type.
To update an existing entry, delete it first then create a new one.
At least one of ``note`` or ``serves`` must be provided. At least one of ``note`` or ``serves`` must be provided.
Args: Args:
+39
View File
@@ -0,0 +1,39 @@
# Tests
Integration tests for mcp-familywall.
## Prerequisites
Tests require valid Family Wall credentials to run against the live API.
Credentials are loaded from:
1. **OS Keyring** (recommended) — set via `mcp-familywall setup`
2. **Environment variables**`FW_EMAIL` and `FW_PASSWORD`
## Running Tests
```bash
uv run pytest tests/
```
For verbose output:
```bash
uv run pytest tests/ -v
```
## Test Coverage
- Circles management (create, list, update, delete, members)
- Lists and tasks (CRUD operations)
- Categories (system and custom)
- Recipes and meal planning
- Activity feed and reactions
- Date validation and error handling
## Notes
- Tests create temporary circles, lists, and tasks for verification
- All test objects are cleaned up after execution
- Do not run tests against production data — use test credentials only
- Some tests are marked as skipped if premium features are unavailable