chore: finalize v1.0.0 — cleanup, unified errors, date validation
- Move integration tests to tests/; fix .gitignore to scope root-only
- Remove tracked debug artefacts (probe2_stderr/stdout.txt)
- __init__.py: version via importlib.metadata; export create_server in __all__
- server.py: unified JSON error format {"error":"..."} for all tools
- server.py: date validation (YYYY-MM-DD) for all meal-plan tools
- server.py: clear_list reports partial failures (failed_count, failed_ids)
- server.py: -> str annotations on get_circles, get_tasks, get_activities
- server.py: document TODO:94 as known limitation (no name in sortingIndexByTaskList)
- server.py: date validation also added to get_meal_plan
- Add LICENSE (MIT, Marcus van Elst)
- Add CHANGELOG.md (Keep a Changelog, v0.1.0–v1.0.0)
- README.md: restructured by use case; 🔒 marks write tools
- CLAUDE.md: update to v1.0.0 state; condense roadmap history
- SPEC.md: add version stamp
- pyproject.toml: version 1.0.0 (single source of truth)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-6
@@ -42,10 +42,11 @@ htmlcov/
|
|||||||
# uv
|
# uv
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
||||||
# Debug and test scripts
|
# Debug and probe scripts (root-level only)
|
||||||
debug_*.py
|
/debug_*.py
|
||||||
probe_*.py
|
/probe_*.py
|
||||||
probe_*.txt
|
/probe_*.txt
|
||||||
p*_err.txt
|
/probe_out.txt
|
||||||
test_*.py
|
/p*_err.txt
|
||||||
|
/test_*.py
|
||||||
reference/
|
reference/
|
||||||
|
|||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
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/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] – 2026-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `tests/` directory with integration tests (moved from root)
|
||||||
|
- `LICENSE` (MIT, Marcus van Elst)
|
||||||
|
- Date validation (ISO `YYYY-MM-DD`) for all meal-plan tools
|
||||||
|
- `_err()` helper — all tools now return `{"error": "..."}` JSON on failure
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Version is now a single source of truth in `pyproject.toml`;
|
||||||
|
`__init__.py` reads it via `importlib.metadata`
|
||||||
|
- `create_server()` exported via `__all__` in `__init__.py`
|
||||||
|
- `clear_list` reports partial failures:
|
||||||
|
`{"deleted_count": N, "failed_count": M, "failed_ids": [...]}`
|
||||||
|
- All tool functions have explicit `-> str` return-type annotations
|
||||||
|
- Unified error format: every tool returns `{"error": "message"}` JSON
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- ~50 debug/probe artefact files (`probe_*.py`, `debug_*.py`, `*.txt` outputs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.11.x] – Meal Planner write access
|
||||||
|
|
||||||
|
### [0.11.8]
|
||||||
|
- Documented `isRecipe` flag behaviour in `get_recipe_box`
|
||||||
|
|
||||||
|
### [0.11.7]
|
||||||
|
- `get_recipe_box` — returns only real recipes (`isRecipe=true`)
|
||||||
|
- Parser fix: `ingredients_parsed` built from free-text, not the API list
|
||||||
|
|
||||||
|
### [0.11.6]
|
||||||
|
- `clear_list` — bulk-delete all tasks in a list within one session
|
||||||
|
|
||||||
|
### [0.11.5]
|
||||||
|
- `add_meal_note` — add note + serving count to a meal plan slot
|
||||||
|
|
||||||
|
### [0.11.4]
|
||||||
|
- `delete_meal_plan_entry` — delete `dish/` and `meal/` entries
|
||||||
|
|
||||||
|
### [0.11.3]
|
||||||
|
- `add_meal_to_meal_plan` — structured output (fixed `a00.r.r` array shape)
|
||||||
|
|
||||||
|
### [0.11.2]
|
||||||
|
- `add_meal_to_meal_plan` — add free-text meal entry
|
||||||
|
|
||||||
|
### [0.11.1]
|
||||||
|
- `add_recipe_to_meal_plan` — structured output after response verified
|
||||||
|
|
||||||
|
### [0.11.0]
|
||||||
|
- `add_recipe_to_meal_plan` — add recipe from recipe box to meal plan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.10.x] – Meal Planner read access
|
||||||
|
|
||||||
|
### [0.10.3]
|
||||||
|
- `get_meal_plan` — `is_from_recipe_box` field via `recipeList[].isRecipe`
|
||||||
|
|
||||||
|
### [0.10.2]
|
||||||
|
- `get_meal_plan` — merges `mealList[]` (free-text notes + servings)
|
||||||
|
|
||||||
|
### [0.10.1]
|
||||||
|
- `get_meal_plan` — structured output after `mplistinterval` response verified
|
||||||
|
|
||||||
|
### [0.10.0]
|
||||||
|
- `get_meal_plan` — read-only, raw JSON (Premium feature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.x] – Task recurrency + reminders
|
||||||
|
|
||||||
|
### [0.9.1]
|
||||||
|
- Fix `reminder` mapping (`reminderUnit`/`reminderValue`; `value=0` is valid)
|
||||||
|
|
||||||
|
### [0.9.0]
|
||||||
|
- `get_tasks` returns `recurrency`, `recurrency_interval`, `rrule`, `reminder`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.x] – Recipe categories + misc fixes
|
||||||
|
|
||||||
|
### [0.8.3]
|
||||||
|
- `OTHER` list type documented and supported; `FW_DEBUG=1` logs unknown task fields
|
||||||
|
|
||||||
|
### [0.8.2]
|
||||||
|
- `get_lists()` without scope returns all circles
|
||||||
|
|
||||||
|
### [0.8.1]
|
||||||
|
- Fix recipe category handling
|
||||||
|
|
||||||
|
### [0.8.0]
|
||||||
|
- `get_recipe_categories` — list available recipe categories
|
||||||
|
- `create_recipe` / `update_recipe` accept `category_ids`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.x] – Circle management
|
||||||
|
|
||||||
|
### [0.7.5]
|
||||||
|
- Primary-circle guard in `update_circle` (`isFirstFamily` check)
|
||||||
|
|
||||||
|
### [0.7.4]
|
||||||
|
- `update_circle` — rename a circle
|
||||||
|
|
||||||
|
### [0.7.3]
|
||||||
|
- `update_list` — rename, change emoji/colour
|
||||||
|
|
||||||
|
### [0.7.2]
|
||||||
|
- `delete_circle`
|
||||||
|
|
||||||
|
### [0.7.1]
|
||||||
|
- `get_lists` scope fix; `create_list` with `circle_id`; `delete_list` scope
|
||||||
|
|
||||||
|
### [0.7.0]
|
||||||
|
- `create_circle`, `add_member_to_circle`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.x] – Recipe box
|
||||||
|
|
||||||
|
### [0.6.1]
|
||||||
|
- `update_recipe` + fix newline handling in `create_recipe`
|
||||||
|
|
||||||
|
### [0.6.0]
|
||||||
|
- `get_recipes`, `get_recipe`, `create_recipe`, `delete_recipe`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.x] – List management
|
||||||
|
|
||||||
|
### [0.5.3]
|
||||||
|
- Category auto-assign hint in `create_task` docstring
|
||||||
|
|
||||||
|
### [0.5.2]
|
||||||
|
- Quantity convention documented in `create_task` docstring
|
||||||
|
|
||||||
|
### [0.5.1]
|
||||||
|
- `emoji` + `color` in `get_lists` / `create_list`
|
||||||
|
|
||||||
|
### [0.5.0]
|
||||||
|
- `create_list`, `delete_list`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.x] – Category management + task fields
|
||||||
|
|
||||||
|
- `create_category`, `delete_category`
|
||||||
|
- `create_task` / `update_task` support `due_date`, `assignee`, `list_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.x] – Task write access
|
||||||
|
|
||||||
|
- `create_task`, `update_task`, `toggle_task`, `delete_task`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.x] – Activity feed
|
||||||
|
|
||||||
|
- `get_activities`, `like_post`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] – Initial release
|
||||||
|
|
||||||
|
- `get_circles`, `get_members`, `get_lists`, `get_tasks`, `get_categories`
|
||||||
|
- Session strategy: login → API calls → logout per tool invocation
|
||||||
|
- OS keyring credential storage
|
||||||
|
- MCP server via FastMCP
|
||||||
|
|
||||||
|
[1.0.0]: https://gitea.gecheckt.de/marcus/mcp-familywall/compare/v0.11.8...v1.0.0
|
||||||
@@ -24,56 +24,30 @@ und wird in Claude Desktop eingebunden.
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
### Implementierte Tools (v0.11.8)
|
### Version: **v1.0.0** ← aktuell
|
||||||
|
|
||||||
|
### Implementierte Tools
|
||||||
|
|
||||||
| Kategorie | Tools |
|
| Kategorie | Tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Lesen | `get_circles`, `get_members`, `get_lists`, `get_tasks`, `get_categories`, `get_activities`, `get_meal_plan` |
|
| Kreise & Mitglieder | `get_circles`, `get_members`, `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` |
|
||||||
| Tasks | `create_task`, `update_task`, `toggle_task`, `delete_task`, `clear_list` |
|
| 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 | `create_list`, `update_list`, `delete_list` |
|
| Rezeptbox | `get_recipe_categories`, `get_recipe_box`, `get_recipes`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe` |
|
||||||
| Kategorien | `create_category`, `delete_category` |
|
|
||||||
| Aktivitäten | `like_post` |
|
|
||||||
| Rezepte | `get_recipes`, `get_recipe_box`, `get_recipe`, `create_recipe`, `update_recipe`, `delete_recipe`, `get_recipe_categories` |
|
|
||||||
| 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` |
|
||||||
| Kreise | `create_circle`, `update_circle`, `delete_circle`, `add_member_to_circle` |
|
|
||||||
|
|
||||||
## Roadmap
|
### Roadmap (Nächstes)
|
||||||
|
|
||||||
- v0.4.x: Kategorie-Management, Task-Felder (due_date, assignee, list_id) ✓
|
|
||||||
- v0.5.x: Listen-Management (create_list, delete_list) ✓
|
|
||||||
- v0.5.1: emoji + color in get_lists / create_list ✓
|
|
||||||
- v0.5.2: Mengenkonvention im create_task Docstring ✓
|
|
||||||
- v0.5.3: Kategorie-Auto-Assign-Hinweis im create_task Docstring ✓ (nachgeliefert in v0.6.1)
|
|
||||||
- v0.6.0: Rezept-Box (get_recipes, get_recipe, create_recipe, delete_recipe) ✓
|
|
||||||
- v0.6.1: update_recipe + Bugfix Zeilenumbrüche in create_recipe ✓
|
|
||||||
- v0.7.0: create_circle + add_member_to_circle ✓
|
|
||||||
- v0.7.1: get_lists scope fix + create_list circle_id + delete_list scope ✓
|
|
||||||
- v0.7.2: delete_circle ✓
|
|
||||||
- v0.7.3: update_list (Umbenennen, emoji/color ändern) ✓
|
|
||||||
- v0.7.4: update_circle (Kreis umbenennen) ✓
|
|
||||||
- v0.7.5: Primärkreis-Schutz in update_circle (isFirstFamily-Check) ✓
|
|
||||||
- v0.8.0: Rezept-Kategorien (get_recipe_categories, create_recipe + category_ids, update_recipe + category_ids) ✓
|
|
||||||
- v0.8.1: Bugfixes (recipe categories) ✓
|
|
||||||
- v0.8.2: get_lists() ohne scope → alle Kreise ✓
|
|
||||||
- v0.8.3: OTHER-Listentyp dokumentiert + create_list unterstützt ihn; FW_DEBUG=1 loggt unbekannte Task-Felder (Vorbereitung Wiederholungen) ✓
|
|
||||||
- v0.8.x: mpadditemtolist (gestrichen – Family Wall kann das nativ)
|
|
||||||
- v0.9.0: get_tasks liefert recurrency, recurrency_interval, rrule, reminder (read-only) ✓
|
|
||||||
- v0.9.1: Bugfix reminder-Mapping (reminderUnit/reminderValue statt unit/value; value=0 ist gültig) ✓
|
|
||||||
- v0.10.0: get_meal_plan (read-only, raw JSON; Premium-Feature Essensplaner) ✓
|
|
||||||
- v0.10.1: get_meal_plan strukturierter Output + SPEC.md mplistinterval Response verifiziert ✓
|
|
||||||
- v0.10.2: get_meal_plan mealList[] einbinden (Freitext-Notizen + Portionen), merged + sortiert ✓
|
|
||||||
- v0.10.3: get_meal_plan is_from_recipe_box Feld (recipeList[].isRecipe Lookup) ✓
|
|
||||||
- v0.11.0: add_recipe_to_meal_plan (mpcreateByRecipeId; raw response bis Struktur verifiziert) ✓
|
|
||||||
- v0.11.1: add_recipe_to_meal_plan strukturierter Output (Response verifiziert) ✓
|
|
||||||
- v0.11.2: add_meal_to_meal_plan (mpcreate; Freitext; raw response bis Struktur verifiziert) ✓
|
|
||||||
- v0.11.3: add_meal_to_meal_plan strukturierter Output (a00.r.r ist Array, nicht Objekt) ✓
|
|
||||||
- v0.11.4: delete_meal_plan_entry (metadelete für dish/ und meal/-Objekte) ✓
|
|
||||||
- v0.11.5: add_meal_note (mpmealput; Notiz + Portionen; strukturierter Output) ✓
|
|
||||||
- v0.11.6: clear_list (alle Tasks einer Liste in einer Session löschen; bulk delete) ✓
|
|
||||||
- v0.11.7: get_recipe_box (nur echte Rezepte, isRecipe=true) + Parser-Fix ingredients_parsed aus Freitext statt API-List ✓
|
|
||||||
- v0.11.8: get_recipe_box isRecipe-Flag-Dokumentation (Freitext-Stubs vs. echte Rezepte) ✓ ← aktuell
|
|
||||||
- v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren)
|
- v2.0: Schreibzugriff auf Wall-Posts (Erstellen, Kommentieren)
|
||||||
|
|
||||||
|
### Historische Meilensteine (kompakt)
|
||||||
|
|
||||||
|
- v0.1–v0.4: Lesen (Kreise, Listen, Tasks, Kategorien), Schreiben (Tasks, Kategorien)
|
||||||
|
- v0.5: Listen-Management (create/delete), emoji + color
|
||||||
|
- v0.6–v0.8: Rezeptbox vollständig, Kreismanagement, Rezeptkategorien
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
|
||||||
## Architektur-Entscheidungen
|
## Architektur-Entscheidungen
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Marcus van Elst
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,47 +1,63 @@
|
|||||||
# mcp-familywall
|
# mcp-familywall
|
||||||
|
|
||||||
MCP server for [Family Wall](https://www.familywall.com) -- read and manage your family's circles, lists, tasks, and recipes 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.
|
||||||
|
|
||||||
## Features (v0.11.8)
|
## Tools (v1.0.0)
|
||||||
|
|
||||||
### Read
|
### Circles & Members
|
||||||
|
|
||||||
- `get_circles` -- list all family circles
|
| Tool | Description |
|
||||||
- `get_members` -- list members of a circle (or all circles)
|
|---|---|
|
||||||
- `get_lists` -- list all task lists (includes `emoji`, `color`, `circle_id`; `null` when unset; list types: `SHOPPING_LIST`, `TODOS`, `OTHER`); without scope parameter returns lists from **all circles**; optional `scope` parameter filters by circle metaId or circle name
|
| `get_circles` | List all family circles |
|
||||||
- `get_tasks` -- list tasks in a specific list (includes `category_id`, `due_date`, `assignee_ids`, `recurrency`, `rrule`, `reminder`)
|
| `get_members` | List members of a circle (or all circles) |
|
||||||
- `get_categories` -- list categories for a list (locale-filtered; custom categories always included; `custom` flag marks user-created ones)
|
| `create_circle` 🔒 | Create a new Family Wall circle |
|
||||||
- `get_activities` -- list recent wall activities (author resolved to display name)
|
| `update_circle` 🔒 | Rename a circle (primary circle protected) |
|
||||||
- `get_recipes` -- list all family recipes (compact summary: id, name, prep/cook time, serves)
|
| `add_member_to_circle` 🔒 | Invite a person to a circle by e-mail |
|
||||||
- `get_recipe_box` -- list only real recipes from the recipe box (excludes stubs created for free-text meal entries)
|
| `delete_circle` 🔒 | Permanently delete a circle and all its content |
|
||||||
- `get_recipe` -- get a single recipe in full detail (ingredients, instructions, ingredients_parsed, category_ids, etc.; ingredients_parsed generated from free-text field to avoid parser bugs)
|
|
||||||
- `get_recipe_categories` -- list all available recipe categories (always returns all 5 standard categories: Bei Kindern beliebt, Wirklich einfach, Nachspeisen, Schmeckt toll, Gemüse; plus any additional categories found in existing recipes)
|
|
||||||
- `get_meal_plan` -- get meal plan entries for a date range (Premium feature; merges dish and meal entries, sorted by date + type; fields: id, date, type, name, recipe_id, is_from_recipe_box, note, serves, can_update, can_delete)
|
|
||||||
|
|
||||||
### Write (with confirmation prompt)
|
### Lists & Tasks
|
||||||
|
|
||||||
- `create_task` -- create a new task in a list (supports `category_id`, `due_date`, `assignee_ids`); use `"Äpfel (5x)"` format for quantities
|
| Tool | Description |
|
||||||
- `update_task` -- update text, description, category, due date, assignees, or move to a different list; supports `clear_due_date=True` to remove a due date
|
|---|---|
|
||||||
- `toggle_task` -- mark a task complete or reopen it
|
| `get_lists` | List all task lists (emoji, color, circle; optional `scope` filter) |
|
||||||
- `delete_task` -- permanently delete a task
|
| `get_tasks` | List tasks in a list (category, due date, assignees, recurrency, reminder) |
|
||||||
- `clear_list` -- delete all tasks in a list in a single session (bulk delete; optional `only_open=True` to keep completed tasks)
|
| `get_categories` | List categories for a list (locale-filtered; custom categories always shown) |
|
||||||
- `create_list` -- create a new task list (`SHOPPING_LIST`, `TODOS`, or `OTHER`; optional `emoji`, `color`, and `circle_id` to target a specific circle)
|
| `get_activities` | List recent wall activities (author resolved to display name) |
|
||||||
- `update_list` -- rename a list or change its emoji/color (partial update — omitted fields unchanged; system lists are protected)
|
| `create_list` 🔒 | Create a task list (`SHOPPING_LIST`, `TODOS`, or `OTHER`; optional emoji, color, circle) |
|
||||||
- `delete_list` -- permanently delete a list and all its tasks (system lists are protected)
|
| `update_list` 🔒 | Rename a list or change its emoji/color (partial update; system lists protected) |
|
||||||
- `create_category` -- create a custom category for a shopping list (with optional icon)
|
| `delete_list` 🔒 | Permanently delete a list and all its tasks (system lists protected) |
|
||||||
- `delete_category` -- delete a custom category (system categories are protected)
|
| `create_category` 🔒 | Create a custom category (with optional icon) |
|
||||||
- `like_post` -- like a wall post/activity
|
| `delete_category` 🔒 | Delete a custom category (system categories protected) |
|
||||||
- `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url, category_ids); use `\n` to separate ingredient lines
|
| `create_task` 🔒 | Create a task (supports category, due date, assignees; use `"Äpfel (5x)"` for quantities) |
|
||||||
- `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged; supports `category_ids` list to change categories)
|
| `update_task` 🔒 | Update text, category, due date, assignees, or move to a different list |
|
||||||
- `delete_recipe` -- permanently delete a recipe (only own recipes)
|
| `toggle_task` 🔒 | Mark a task complete or reopen it |
|
||||||
- `add_recipe_to_meal_plan` -- add a recipe from the recipe box to the meal plan for a specific date and meal slot (BREAKFAST/LUNCH/SNACK/DINNER)
|
| `delete_task` 🔒 | Permanently delete a task |
|
||||||
- `add_meal_to_meal_plan` -- add a free-text meal entry (no recipe) to the meal plan for a specific date and meal slot
|
| `clear_list` 🔒 | Bulk-delete all tasks in a list (optional `only_open=True` keeps completed tasks) |
|
||||||
- `add_meal_note` -- add a note and/or serving count to a meal plan slot (creates a `meal/` entry; at least one of `note` or `serves` required)
|
| `like_post` 🔒 | Like a wall post/activity |
|
||||||
- `delete_meal_plan_entry` -- permanently delete a meal plan entry (works for both `dish/…` and `meal/…` entries)
|
|
||||||
- `create_circle` -- create a new Family Wall circle (group)
|
### Recipe Box
|
||||||
- `update_circle` -- rename a circle (server capitalises first letter; only name is changeable via API; primary circle is protected)
|
|
||||||
- `delete_circle` -- permanently delete a circle and all its content (primary circle is protected)
|
| Tool | Description |
|
||||||
- `add_member_to_circle` -- invite a person to a circle by e-mail (for new Family Wall users only; existing accounts require in-app invitation)
|
|---|---|
|
||||||
|
| `get_recipe_categories` | List all recipe categories |
|
||||||
|
| `get_recipe_box` | List real recipes only (excludes free-text meal stubs) |
|
||||||
|
| `get_recipes` | List all recipe entries including stubs (compact summary) |
|
||||||
|
| `get_recipe` | Get a single recipe in full detail (ingredients, instructions, parsed ingredients) |
|
||||||
|
| `create_recipe` 🔒 | Create a new recipe (name, ingredients, instructions, prep/cook time, serves, url, categories) |
|
||||||
|
| `update_recipe` 🔒 | Update any field of a recipe (partial update; supports category_ids) |
|
||||||
|
| `delete_recipe` 🔒 | Permanently delete a recipe |
|
||||||
|
|
||||||
|
### Meal Planner (Premium)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `get_meal_plan` | Get meal plan entries for a date range (dish + meal entries, sorted by date + slot) |
|
||||||
|
| `add_recipe_to_meal_plan` 🔒 | Add a recipe-box recipe to a meal slot (BREAKFAST/LUNCH/SNACK/DINNER) |
|
||||||
|
| `add_meal_to_meal_plan` 🔒 | Add a free-text meal entry to a meal slot |
|
||||||
|
| `add_meal_note` 🔒 | Add a note and/or serving count to a meal slot |
|
||||||
|
| `delete_meal_plan_entry` 🔒 | Delete a meal plan entry (`dish/…` or `meal/…`) |
|
||||||
|
|
||||||
|
🔒 = requires user confirmation before calling
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -107,4 +123,4 @@ Credentials are resolved in this order:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
[MIT](LICENSE) — © 2026 Marcus van Elst
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Erarbeitet durch Browser-Traffic-Analyse und React-Fiber-Analyse (April 2026).
|
Erarbeitet durch Browser-Traffic-Analyse und React-Fiber-Analyse (April 2026).
|
||||||
Es gibt keine offizielle API-Dokumentation.
|
Es gibt keine offizielle API-Dokumentation.
|
||||||
|
|
||||||
|
**Stand: v1.0.0 (2026-04-17)**
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
https://api.familywall.com/api
|
https://api.familywall.com/api
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mcp-familywall"
|
name = "mcp-familywall"
|
||||||
version = "0.11.8"
|
version = "1.0.0"
|
||||||
description = "MCP server for Family Wall — read your family's lists and tasks 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"
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
__version__ = "0.11.8"
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("mcp-familywall")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "unknown"
|
||||||
|
|
||||||
|
__all__ = ["create_server", "__version__"]
|
||||||
|
|||||||
+123
-90
@@ -6,6 +6,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -31,6 +32,18 @@ mcp = FastMCP("familywall")
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> str:
|
||||||
|
"""Return a JSON-encoded error response."""
|
||||||
|
return json.dumps({"error": msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_date(date: str) -> str | None:
|
||||||
|
"""Return None when *date* is a valid ISO YYYY-MM-DD string, else an error message."""
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||||
|
return f"Invalid date format {date!r}. Expected ISO YYYY-MM-DD (e.g. '2026-04-20')."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _accgetallfamily() -> dict[str, Any]:
|
def _accgetallfamily() -> dict[str, Any]:
|
||||||
"""Login, call accgetallfamily, logout and return the response body.
|
"""Login, call accgetallfamily, logout and return the response body.
|
||||||
|
|
||||||
@@ -91,7 +104,7 @@ def _extract_lists(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": list_id,
|
"id": list_id,
|
||||||
"name": list_id, # real name unknown — TODO once field identified
|
"name": list_id, # no name in sortingIndexByTaskList; get_lists has full data
|
||||||
"type": None,
|
"type": None,
|
||||||
"open": None,
|
"open": None,
|
||||||
"total": None,
|
"total": None,
|
||||||
@@ -128,12 +141,12 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_circles():
|
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}."""
|
||||||
try:
|
try:
|
||||||
raw_circles = _famlistfamily()
|
raw_circles = _famlistfamily()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
result = [{"id": c.get("metaId"), "name": c.get("name")} for c in raw_circles]
|
result = [{"id": c.get("metaId"), "name": c.get("name")} for c in raw_circles]
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
@@ -214,7 +227,7 @@ def get_members(circle_id: str | None = None) -> str:
|
|||||||
try:
|
try:
|
||||||
raw_circles = _famlistfamily()
|
raw_circles = _famlistfamily()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
@@ -298,7 +311,7 @@ def get_lists(scope: str | None = None) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Build the scope param for taskgettasklists.
|
# Build the scope param for taskgettasklists.
|
||||||
# When scope is provided as a circle name (not a metaId), we need to
|
# When scope is provided as a circle name (not a metaId), we need to
|
||||||
@@ -312,7 +325,7 @@ def get_lists(scope: str | None = None) -> str:
|
|||||||
try:
|
try:
|
||||||
circles = _famlistfamily()
|
circles = _famlistfamily()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
matched = next((c for c in circles if c.get("name") == scope), None)
|
matched = next((c for c in circles if c.get("name") == scope), None)
|
||||||
if matched is None:
|
if matched is None:
|
||||||
circle_names = [c.get("name") for c in circles]
|
circle_names = [c.get("name") for c in circles]
|
||||||
@@ -331,7 +344,7 @@ def get_lists(scope: str | None = None) -> str:
|
|||||||
circles = _famlistfamily()
|
circles = _famlistfamily()
|
||||||
api_scopes = [c["metaId"] for c in circles if "metaId" in c]
|
api_scopes = [c["metaId"] for c in circles if "metaId" in c]
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with FamilyWallClient() as client:
|
with FamilyWallClient() as client:
|
||||||
@@ -357,9 +370,9 @@ def get_lists(scope: str | None = None) -> str:
|
|||||||
|
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for item in all_lists:
|
for item in all_lists:
|
||||||
@@ -388,12 +401,12 @@ def get_lists(scope: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_tasks(list_id: str, only_open: bool = True):
|
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. list_id from get_lists. only_open=True filters completed."""
|
||||||
try:
|
try:
|
||||||
data = _accgetallfamily()
|
data = _accgetallfamily()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
raw_tasks = _extract_tasks(data)
|
raw_tasks = _extract_tasks(data)
|
||||||
|
|
||||||
@@ -530,7 +543,7 @@ def get_categories(list_id: str, locale: str = "de") -> str:
|
|||||||
try:
|
try:
|
||||||
data = _accgetallfamily()
|
data = _accgetallfamily()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_cats = data["a01"]["r"]["r"]["updatedCreated"]
|
raw_cats = data["a01"]["r"]["r"]["updatedCreated"]
|
||||||
@@ -614,7 +627,7 @@ def create_category(list_id: str, name: str, icon: str | None = None) -> str:
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("taskcategoryput", params)
|
data = _authenticated_call("taskcategoryput", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cat_obj = data["a00"]["r"]["r"]
|
cat_obj = data["a00"]["r"]["r"]
|
||||||
@@ -663,7 +676,7 @@ def delete_category(category_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
cat_obj: dict[str, Any] | None = None
|
cat_obj: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
@@ -683,7 +696,7 @@ def delete_category(category_id: str) -> str:
|
|||||||
cat_obj = next((c for c in raw_cats if c.get("metaId") == category_id), None)
|
cat_obj = next((c for c in raw_cats if c.get("metaId") == category_id), None)
|
||||||
if cat_obj is None:
|
if cat_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: Category '{category_id}' not found."
|
return _err(f"Category '{category_id}' not found.")
|
||||||
|
|
||||||
can_delete: str | None = cat_obj.get("rights", {}).get("canDelete")
|
can_delete: str | None = cat_obj.get("rights", {}).get("canDelete")
|
||||||
if can_delete != "true":
|
if can_delete != "true":
|
||||||
@@ -705,9 +718,9 @@ def delete_category(category_id: str) -> str:
|
|||||||
client.call("taskcategorydelete", {"id": category_id})
|
client.call("taskcategorydelete", {"id": category_id})
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"deleted": True, "id": category_id, "name": cat_obj.get("name")},
|
{"deleted": True, "id": category_id, "name": cat_obj.get("name")},
|
||||||
@@ -722,12 +735,12 @@ def delete_category(category_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_activities(limit: int = 20):
|
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 wall activities as JSON. limit controls max number of results."""
|
||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Load member data to resolve author IDs to display names.
|
# Load member data to resolve author IDs to display names.
|
||||||
# Non-fatal: fall back to raw account IDs when member lookup fails.
|
# Non-fatal: fall back to raw account IDs when member lookup fails.
|
||||||
@@ -748,9 +761,9 @@ def get_activities(limit: int = 20):
|
|||||||
data = client.call("wallget", {"nb": str(limit)})
|
data = client.call("wallget", {"nb": str(limit)})
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
# Try known response patterns; fall back to raw JSON for verification.
|
# Try known response patterns; fall back to raw JSON for verification.
|
||||||
raw_activities: list[dict[str, Any]] | None = None
|
raw_activities: list[dict[str, Any]] | None = None
|
||||||
@@ -881,7 +894,7 @@ def create_task(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("taskcreate2", params)
|
data = _authenticated_call("taskcreate2", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Try to extract the new task's metaId from the response.
|
# Try to extract the new task's metaId from the response.
|
||||||
try:
|
try:
|
||||||
@@ -939,7 +952,7 @@ def update_task(
|
|||||||
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 "Error: 'clear_due_date' and 'due_date' cannot be used together."
|
return _err("'clear_due_date' and 'due_date' cannot be used together.")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
text is None
|
text is None
|
||||||
@@ -950,8 +963,8 @@ def update_task(
|
|||||||
and assignee_ids is None
|
and assignee_ids is None
|
||||||
and list_id is None
|
and list_id is None
|
||||||
):
|
):
|
||||||
return (
|
return _err(
|
||||||
"Error: 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', or 'list_id' must be provided."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -975,7 +988,7 @@ def update_task(
|
|||||||
try:
|
try:
|
||||||
_authenticated_call("taskupdate2", params)
|
_authenticated_call("taskupdate2", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# A response without 'ex'/'un' keys is treated as success by fw_client.
|
# A response without 'ex'/'un' keys is treated as success by fw_client.
|
||||||
return json.dumps({"updated": True, "id": task_id}, ensure_ascii=False, indent=2)
|
return json.dumps({"updated": True, "id": task_id}, ensure_ascii=False, indent=2)
|
||||||
@@ -1005,7 +1018,7 @@ def toggle_task(task_id: str, complete: bool) -> str:
|
|||||||
try:
|
try:
|
||||||
_authenticated_call("taskmark", params)
|
_authenticated_call("taskmark", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"toggled": True, "id": task_id, "complete": complete},
|
{"toggled": True, "id": task_id, "complete": complete},
|
||||||
@@ -1036,7 +1049,7 @@ def delete_task(task_id: str) -> str:
|
|||||||
"metadelete", {"id": task_id}
|
"metadelete", {"id": task_id}
|
||||||
) # verified: metadelete uses 'id', not 'metaId'
|
) # verified: metadelete uses 'id', not 'metaId'
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
return json.dumps({"deleted": True, "id": task_id}, ensure_ascii=False, indent=2)
|
return json.dumps({"deleted": True, "id": task_id}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -1066,12 +1079,12 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
|
|||||||
or an error message.
|
or an error message.
|
||||||
"""
|
"""
|
||||||
if not list_id.startswith("taskList/"):
|
if not list_id.startswith("taskList/"):
|
||||||
return "Error: list_id must start with 'taskList/'"
|
return _err("list_id must start with 'taskList/'")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with FamilyWallClient() as client:
|
with FamilyWallClient() as client:
|
||||||
@@ -1090,20 +1103,30 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
|
|||||||
and (not only_open or str(t.get("complete", "false")).lower() != "true")
|
and (not only_open or str(t.get("complete", "false")).lower() != "true")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
deleted_ids: list[str] = []
|
||||||
|
failed_ids: list[str] = []
|
||||||
for task in tasks_to_delete:
|
for task in tasks_to_delete:
|
||||||
client.call("metadelete", {"id": task["metaId"]})
|
meta_id: str = task["metaId"]
|
||||||
|
try:
|
||||||
|
client.call("metadelete", {"id": meta_id})
|
||||||
|
deleted_ids.append(meta_id)
|
||||||
|
except Exception:
|
||||||
|
failed_ids.append(meta_id)
|
||||||
|
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
result: dict[str, Any] = {
|
||||||
{"deleted_count": len(tasks_to_delete), "list_id": list_id},
|
"deleted_count": len(deleted_ids),
|
||||||
ensure_ascii=False,
|
"list_id": list_id,
|
||||||
indent=2,
|
}
|
||||||
)
|
if failed_ids:
|
||||||
|
result["failed_count"] = len(failed_ids)
|
||||||
|
result["failed_ids"] = failed_ids
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1142,9 +1165,9 @@ def create_list(
|
|||||||
created in.
|
created in.
|
||||||
"""
|
"""
|
||||||
if list_type not in ("SHOPPING_LIST", "TODOS", "OTHER"):
|
if list_type not in ("SHOPPING_LIST", "TODOS", "OTHER"):
|
||||||
return "Error: list_type must be 'SHOPPING_LIST', 'TODOS', or 'OTHER'."
|
return _err("list_type must be 'SHOPPING_LIST', 'TODOS', or 'OTHER'.")
|
||||||
if len(name) > 200:
|
if len(name) > 200:
|
||||||
return "Error: name must not exceed 200 characters."
|
return _err("name must not exceed 200 characters.")
|
||||||
|
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -1162,7 +1185,7 @@ def create_list(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("taskcreatelist", params)
|
data = _authenticated_call("taskcreatelist", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
list_obj = data["a00"]["r"]["r"]
|
list_obj = data["a00"]["r"]["r"]
|
||||||
@@ -1218,7 +1241,7 @@ def delete_list(list_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Derive the owning circle from the list metaId so that secondary-circle
|
# Derive the owning circle from the list metaId so that secondary-circle
|
||||||
# lists can be queried and deleted with the correct scope parameter.
|
# lists can be queried and deleted with the correct scope parameter.
|
||||||
@@ -1245,7 +1268,7 @@ def delete_list(list_id: str) -> str:
|
|||||||
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
||||||
if list_obj is None:
|
if list_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: List '{list_id}' not found."
|
return _err(f"List '{list_id}' not found.")
|
||||||
|
|
||||||
can_delete: str | None = (list_obj.get("rights") or {}).get("canDelete")
|
can_delete: str | None = (list_obj.get("rights") or {}).get("canDelete")
|
||||||
if can_delete != "true":
|
if can_delete != "true":
|
||||||
@@ -1269,9 +1292,9 @@ def delete_list(list_id: str) -> str:
|
|||||||
client.call("taskdeletelist", del_params)
|
client.call("taskdeletelist", del_params)
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"deleted": True, "id": list_id, "name": list_obj.get("name")},
|
{"deleted": True, "id": list_id, "name": list_obj.get("name")},
|
||||||
@@ -1316,12 +1339,12 @@ def update_list(
|
|||||||
JSON with the updated list object on success, or an error message.
|
JSON with the updated list object on success, or an error message.
|
||||||
"""
|
"""
|
||||||
if name is None and color is None and emoji is None:
|
if name is None and color is None and emoji is None:
|
||||||
return "Error: At least one of 'name', 'color', or 'emoji' must be provided."
|
return _err("At least one of 'name', 'color', or 'emoji' must be provided.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Derive the owning circle from the list metaId (same as delete_list).
|
# Derive the owning circle from the list metaId (same as delete_list).
|
||||||
circle_scope = _circle_id_from_list_id(list_id)
|
circle_scope = _circle_id_from_list_id(list_id)
|
||||||
@@ -1346,7 +1369,7 @@ def update_list(
|
|||||||
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
||||||
if list_obj is None:
|
if list_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: List '{list_id}' not found."
|
return _err(f"List '{list_id}' not found.")
|
||||||
|
|
||||||
can_update: str | None = (list_obj.get("rights") or {}).get("canUpdate")
|
can_update: str | None = (list_obj.get("rights") or {}).get("canUpdate")
|
||||||
if can_update != "true":
|
if can_update != "true":
|
||||||
@@ -1375,9 +1398,9 @@ def update_list(
|
|||||||
resp = client.call("taskupdatelist", upd_params)
|
resp = client.call("taskupdatelist", upd_params)
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_obj: dict[str, Any] = resp["a00"]["r"]["r"]
|
updated_obj: dict[str, Any] = resp["a00"]["r"]["r"]
|
||||||
@@ -1434,7 +1457,7 @@ def create_circle(name: str) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with FamilyWallClient() as client:
|
with FamilyWallClient() as client:
|
||||||
@@ -1471,9 +1494,9 @@ def create_circle(name: str) -> str:
|
|||||||
|
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"created": True, "id": circle_id, "name": canonical_name},
|
{"created": True, "id": circle_id, "name": canonical_name},
|
||||||
@@ -1505,12 +1528,12 @@ def update_circle(circle_id: str, name: str) -> str:
|
|||||||
JSON with ``{updated, id, name}`` on success, or an error message.
|
JSON with ``{updated, id, name}`` on success, or an error message.
|
||||||
"""
|
"""
|
||||||
if not name or not name.strip():
|
if not name or not name.strip():
|
||||||
return "Error: 'name' must not be empty."
|
return _err("'name' must not be empty.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with FamilyWallClient() as client:
|
with FamilyWallClient() as client:
|
||||||
@@ -1524,7 +1547,7 @@ def update_circle(circle_id: str, name: str) -> str:
|
|||||||
raise TypeError("a00.r.r is not a list")
|
raise TypeError("a00.r.r is not a list")
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
client.logout()
|
client.logout()
|
||||||
return "Error: Unexpected famlistfamily response structure."
|
return _err("Unexpected famlistfamily response structure.")
|
||||||
|
|
||||||
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -1561,9 +1584,9 @@ def update_circle(circle_id: str, name: str) -> str:
|
|||||||
resp = client.call("accupdatefamily", {"scope": circle_id, "name": name})
|
resp = client.call("accupdatefamily", {"scope": circle_id, "name": name})
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
# Response: a00.r.r = full circle object
|
# Response: a00.r.r = full circle object
|
||||||
try:
|
try:
|
||||||
@@ -1624,7 +1647,7 @@ def add_member_to_circle(
|
|||||||
JSON success indicator or an error message.
|
JSON success indicator or an error message.
|
||||||
"""
|
"""
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
return "Error: 'email' must be a valid e-mail address."
|
return _err("'email' must be a valid e-mail address.")
|
||||||
|
|
||||||
# Derive a sensible first-name fallback from the email local part.
|
# Derive a sensible first-name fallback from the email local part.
|
||||||
if firstname is None:
|
if firstname is None:
|
||||||
@@ -1641,7 +1664,7 @@ def add_member_to_circle(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("accinvite", params)
|
data = _authenticated_call("accinvite", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# On success the server returns the invitation object under a00.r.r.
|
# On success the server returns the invitation object under a00.r.r.
|
||||||
try:
|
try:
|
||||||
@@ -1686,7 +1709,7 @@ def delete_circle(circle_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
circle_name: str = circle_id
|
circle_name: str = circle_id
|
||||||
try:
|
try:
|
||||||
@@ -1701,7 +1724,7 @@ def delete_circle(circle_id: str) -> str:
|
|||||||
raise TypeError("a00.r.r is not a list")
|
raise TypeError("a00.r.r is not a list")
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
client.logout()
|
client.logout()
|
||||||
return "Error: Unexpected famlistfamily response structure."
|
return _err("Unexpected famlistfamily response structure.")
|
||||||
|
|
||||||
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -1739,9 +1762,9 @@ def delete_circle(circle_id: str) -> str:
|
|||||||
client.call("adminwipefamily", {"scope": circle_id})
|
client.call("adminwipefamily", {"scope": circle_id})
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"deleted": True, "id": circle_id, "name": circle_name},
|
{"deleted": True, "id": circle_id, "name": circle_name},
|
||||||
@@ -1795,7 +1818,7 @@ def like_post(post_id: str, like: bool = True) -> str:
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("wallmood", params)
|
data = _authenticated_call("wallmood", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# Extract moodMap from the response to confirm the like was recorded.
|
# Extract moodMap from the response to confirm the like was recorded.
|
||||||
try:
|
try:
|
||||||
@@ -1950,7 +1973,7 @@ def get_recipes() -> str:
|
|||||||
try:
|
try:
|
||||||
raw_recipes = _get_raw_recipes()
|
raw_recipes = _get_raw_recipes()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
result = [parse_recipe_summary(r) for r in raw_recipes]
|
result = [parse_recipe_summary(r) for r in raw_recipes]
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
@@ -1976,7 +1999,7 @@ def get_recipe_box() -> str:
|
|||||||
try:
|
try:
|
||||||
raw_recipes = _get_raw_recipes()
|
raw_recipes = _get_raw_recipes()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
result = [parse_recipe_summary(r) for r in raw_recipes if r.get("isRecipe") == "true"]
|
result = [parse_recipe_summary(r) for r in raw_recipes if r.get("isRecipe") == "true"]
|
||||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
@@ -2003,11 +2026,11 @@ def get_recipe(recipe_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
raw_recipes = _get_raw_recipes()
|
raw_recipes = _get_raw_recipes()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
raw = next((r for r in raw_recipes if r.get("metaId") == recipe_id), None)
|
raw = next((r for r in raw_recipes if r.get("metaId") == recipe_id), None)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return f"Error: Recipe '{recipe_id}' not found."
|
return _err(f"Recipe '{recipe_id}' not found.")
|
||||||
|
|
||||||
return json.dumps(parse_recipe_full(raw), ensure_ascii=False, indent=2)
|
return json.dumps(parse_recipe_full(raw), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -2067,7 +2090,7 @@ def create_recipe(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("mprecipeput", params)
|
data = _authenticated_call("mprecipeput", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recipe_obj = data["a00"]["r"]["r"]
|
recipe_obj = data["a00"]["r"]["r"]
|
||||||
@@ -2110,7 +2133,7 @@ def delete_recipe(recipe_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
recipe_obj: dict[str, Any] | None = None
|
recipe_obj: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
@@ -2127,7 +2150,7 @@ def delete_recipe(recipe_id: str) -> str:
|
|||||||
recipe_obj = next((r for r in items if r.get("metaId") == recipe_id), None)
|
recipe_obj = next((r for r in items if r.get("metaId") == recipe_id), None)
|
||||||
if recipe_obj is None:
|
if recipe_obj is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: Recipe '{recipe_id}' not found."
|
return _err(f"Recipe '{recipe_id}' not found.")
|
||||||
|
|
||||||
can_delete: str | None = (recipe_obj.get("rights") or {}).get("canDelete")
|
can_delete: str | None = (recipe_obj.get("rights") or {}).get("canDelete")
|
||||||
if can_delete != "true":
|
if can_delete != "true":
|
||||||
@@ -2147,9 +2170,9 @@ def delete_recipe(recipe_id: str) -> str:
|
|||||||
client.call("metadelete", {"id": recipe_id})
|
client.call("metadelete", {"id": recipe_id})
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"deleted": True, "id": recipe_id, "name": recipe_obj.get("name")},
|
{"deleted": True, "id": recipe_id, "name": recipe_obj.get("name")},
|
||||||
@@ -2230,7 +2253,7 @@ def update_recipe(
|
|||||||
try:
|
try:
|
||||||
email, password = get_credentials()
|
email, password = get_credentials()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with FamilyWallClient() as client:
|
with FamilyWallClient() as client:
|
||||||
@@ -2246,7 +2269,7 @@ def update_recipe(
|
|||||||
current = next((r for r in items if r.get("metaId") == recipe_id), None)
|
current = next((r for r in items if r.get("metaId") == recipe_id), None)
|
||||||
if current is None:
|
if current is None:
|
||||||
client.logout()
|
client.logout()
|
||||||
return f"Error: Recipe '{recipe_id}' not found."
|
return _err(f"Recipe '{recipe_id}' not found.")
|
||||||
|
|
||||||
can_update: str | None = (current.get("rights") or {}).get("canUpdate")
|
can_update: str | None = (current.get("rights") or {}).get("canUpdate")
|
||||||
if can_update != "true":
|
if can_update != "true":
|
||||||
@@ -2280,9 +2303,9 @@ def update_recipe(
|
|||||||
resp = client.call("mprecipeput", params)
|
resp = client.call("mprecipeput", params)
|
||||||
client.logout()
|
client.logout()
|
||||||
except FamilyWallError as exc:
|
except FamilyWallError as exc:
|
||||||
return f"Error: Family Wall API error: {exc}"
|
return _err(f"Family Wall API error: {exc}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error: Connection error: {exc}"
|
return _err(f"Connection error: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recipe_obj = resp["a00"]["r"]["r"]
|
recipe_obj = resp["a00"]["r"]["r"]
|
||||||
@@ -2337,10 +2360,14 @@ def get_meal_plan(date_from: str, date_to: str) -> str:
|
|||||||
|
|
||||||
Returns an error message string on failure.
|
Returns an error message string on failure.
|
||||||
"""
|
"""
|
||||||
|
if date_err := _validate_date(date_from):
|
||||||
|
return _err(date_err)
|
||||||
|
if date_err := _validate_date(date_to):
|
||||||
|
return _err(date_err)
|
||||||
try:
|
try:
|
||||||
data = _authenticated_call("mplistinterval", {"from": date_from, "to": date_to})
|
data = _authenticated_call("mplistinterval", {"from": date_from, "to": date_to})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = data["a00"]["r"]["r"]
|
payload = data["a00"]["r"]["r"]
|
||||||
@@ -2437,7 +2464,9 @@ def add_recipe_to_meal_plan(
|
|||||||
JSON with the new dish entry on success, or an error message.
|
JSON with the new dish entry on success, or an error message.
|
||||||
"""
|
"""
|
||||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||||
|
if date_err := _validate_date(date):
|
||||||
|
return _err(date_err)
|
||||||
|
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"recipeId": recipe_id,
|
"recipeId": recipe_id,
|
||||||
@@ -2448,7 +2477,7 @@ def add_recipe_to_meal_plan(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("mpcreateByRecipeId", params)
|
data = _authenticated_call("mpcreateByRecipeId", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dish = data["a00"]["r"]["r"]
|
dish = data["a00"]["r"]["r"]
|
||||||
@@ -2506,9 +2535,11 @@ def add_meal_to_meal_plan(
|
|||||||
JSON with the new dish entry on success, or an error message.
|
JSON with the new dish entry on success, or an error message.
|
||||||
"""
|
"""
|
||||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||||
if not name or not name.strip():
|
if not name or not name.strip():
|
||||||
return "Error: 'name' must not be empty."
|
return _err("'name' must not be empty.")
|
||||||
|
if date_err := _validate_date(date):
|
||||||
|
return _err(date_err)
|
||||||
|
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -2519,7 +2550,7 @@ def add_meal_to_meal_plan(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("mpcreate", params)
|
data = _authenticated_call("mpcreate", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
# NOTE: mpcreate returns a00.r.r as an *array*, unlike mpcreateByRecipeId
|
# NOTE: mpcreate returns a00.r.r as an *array*, unlike mpcreateByRecipeId
|
||||||
# which returns a plain object. Take the first (and only) element.
|
# which returns a plain object. Take the first (and only) element.
|
||||||
@@ -2577,15 +2608,15 @@ def delete_meal_plan_entry(entry_id: str) -> str:
|
|||||||
JSON success indicator or an error message.
|
JSON success indicator or an error message.
|
||||||
"""
|
"""
|
||||||
if not entry_id.startswith(("dish/", "meal/")):
|
if not entry_id.startswith(("dish/", "meal/")):
|
||||||
return (
|
return _err(
|
||||||
"Error: entry_id must be a dish or meal metaId "
|
"entry_id must be a dish or meal metaId "
|
||||||
"(e.g. 'dish/16282169_20009811' or 'meal/16282169_1620659')."
|
"(e.g. 'dish/16282169_20009811' or 'meal/16282169_1620659')."
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_authenticated_call("metadelete", {"id": entry_id})
|
_authenticated_call("metadelete", {"id": entry_id})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
return json.dumps({"deleted": True, "id": entry_id}, ensure_ascii=False, indent=2)
|
return json.dumps({"deleted": True, "id": entry_id}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -2620,9 +2651,11 @@ def add_meal_note(
|
|||||||
JSON with the new meal entry on success, or an error message.
|
JSON with the new meal entry on success, or an error message.
|
||||||
"""
|
"""
|
||||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||||
if note is None and serves is None:
|
if note is None and serves is None:
|
||||||
return "Error: At least one of 'note' or 'serves' must be provided."
|
return _err("At least one of 'note' or 'serves' must be provided.")
|
||||||
|
if date_err := _validate_date(date):
|
||||||
|
return _err(date_err)
|
||||||
|
|
||||||
params: dict[str, Any] = {"date": date, "type": meal_type}
|
params: dict[str, Any] = {"date": date, "type": meal_type}
|
||||||
if note is not None:
|
if note is not None:
|
||||||
@@ -2633,7 +2666,7 @@ def add_meal_note(
|
|||||||
try:
|
try:
|
||||||
data = _authenticated_call("mpmealput", params)
|
data = _authenticated_call("mpmealput", params)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return f"Error: {exc}"
|
return _err(str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
meal = data["a00"]["r"]["r"]
|
meal = data["a00"]["r"]["r"]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Integration test for emoji + color in get_lists / create_list (v0.5.1)."""
|
||||||
|
import sys, json
|
||||||
|
sys.path.insert(0, "src")
|
||||||
|
|
||||||
|
import keyring
|
||||||
|
class _K:
|
||||||
|
def get_password(self, s, k): return {"email": "marcus@gecheckt.de", "password": "Lasdas1234"}.get(k)
|
||||||
|
keyring.get_password = _K().get_password
|
||||||
|
|
||||||
|
from mcp_familywall.server import create_list, delete_list, get_lists
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
sys.stdout.buffer.write((str(msg) + "\n").encode("utf-8"))
|
||||||
|
|
||||||
|
def check(label, result, **expect):
|
||||||
|
try:
|
||||||
|
obj = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
errors.append(f"FAIL [{label}]: not JSON -- {result!r}")
|
||||||
|
return None
|
||||||
|
for k, v in expect.items():
|
||||||
|
if obj.get(k) != v:
|
||||||
|
errors.append(f"FAIL [{label}]: {k}={obj.get(k)!r}, expected {v!r}")
|
||||||
|
return None
|
||||||
|
log(f"OK [{label}]: {dict((k, obj.get(k)) for k in expect)}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# --- Test 1: create with emoji + color ---
|
||||||
|
r1 = create_list("__test_emoji__", "TODOS", emoji="\U0001F331", color="#E53935")
|
||||||
|
log(f"create with emoji+color = {r1}")
|
||||||
|
o1 = check("create_emoji_color", r1, created=True, emoji="\U0001F331", color="#E53935")
|
||||||
|
id1 = o1["id"] if o1 else None
|
||||||
|
|
||||||
|
# --- Test 2: create without emoji/color ---
|
||||||
|
r2 = create_list("__test_plain__", "SHOPPING_LIST")
|
||||||
|
log(f"create plain = {r2}")
|
||||||
|
o2 = check("create_plain", r2, created=True, emoji=None, color=None)
|
||||||
|
id2 = o2["id"] if o2 else None
|
||||||
|
|
||||||
|
# --- Test 3: get_lists shows emoji + color ---
|
||||||
|
lists_json = get_lists()
|
||||||
|
try:
|
||||||
|
all_lists = json.loads(lists_json)
|
||||||
|
by_id = {l["id"]: l for l in all_lists}
|
||||||
|
|
||||||
|
if id1 and id1 in by_id:
|
||||||
|
l = by_id[id1]
|
||||||
|
if l.get("emoji") == "\U0001F331" and l.get("color") == "#E53935":
|
||||||
|
log(f"OK [get_lists emoji+color]: emoji={l['emoji']!r} color={l['color']!r}")
|
||||||
|
else:
|
||||||
|
errors.append(f"FAIL [get_lists emoji+color]: emoji={l.get('emoji')!r} color={l.get('color')!r}")
|
||||||
|
elif id1:
|
||||||
|
errors.append(f"FAIL [get_lists emoji+color]: {id1} not in lists")
|
||||||
|
|
||||||
|
if id2 and id2 in by_id:
|
||||||
|
l = by_id[id2]
|
||||||
|
if l.get("emoji") is None and l.get("color") is None:
|
||||||
|
log(f"OK [get_lists plain]: emoji={l.get('emoji')!r} color={l.get('color')!r}")
|
||||||
|
else:
|
||||||
|
errors.append(f"FAIL [get_lists plain]: emoji={l.get('emoji')!r} color={l.get('color')!r}")
|
||||||
|
elif id2:
|
||||||
|
errors.append(f"FAIL [get_lists plain]: {id2} not in lists")
|
||||||
|
|
||||||
|
# Test 4: system lists have emoji=None (API returns ""), color=None (absent)
|
||||||
|
sys_lists = [l for l in all_lists if "SYS-CAT" in l.get("name", "") or
|
||||||
|
l.get("name") in ("Aufgaben", "Einkaufsliste")]
|
||||||
|
for sl in sys_lists:
|
||||||
|
if sl.get("emoji") is None and sl.get("color") is None:
|
||||||
|
log(f"OK [get_lists sys emoji/color=null]: {sl.get('name')}")
|
||||||
|
else:
|
||||||
|
errors.append(f"FAIL [get_lists sys emoji/color]: name={sl.get('name')!r} emoji={sl.get('emoji')!r} color={sl.get('color')!r}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"FAIL [get_lists parse]: {e}")
|
||||||
|
|
||||||
|
# --- Cleanup ---
|
||||||
|
for lid, label in [(id1, "emoji"), (id2, "plain")]:
|
||||||
|
if lid:
|
||||||
|
r = delete_list(lid)
|
||||||
|
try:
|
||||||
|
obj = json.loads(r)
|
||||||
|
if obj.get("deleted"):
|
||||||
|
log(f"OK [cleanup {label}]: deleted {lid}")
|
||||||
|
else:
|
||||||
|
errors.append(f"FAIL [cleanup {label}]: {r}")
|
||||||
|
except Exception:
|
||||||
|
errors.append(f"FAIL [cleanup {label}]: {r}")
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
if errors:
|
||||||
|
log("\nFAILURES:")
|
||||||
|
for e in errors:
|
||||||
|
log(" " + e)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
log("\nAll tests passed.")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Integration test for create_list and delete_list (v0.5.0)."""
|
||||||
|
import sys, json, os
|
||||||
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
sys.path.insert(0, "src")
|
||||||
|
|
||||||
|
# Patch keyring to use test credentials
|
||||||
|
import keyring
|
||||||
|
class _FakeKeyring:
|
||||||
|
_store = {"email": "marcus@gecheckt.de", "password": "Lasdas1234"}
|
||||||
|
def get_password(self, service, key):
|
||||||
|
return self._store.get(key)
|
||||||
|
keyring.get_password = _FakeKeyring().get_password
|
||||||
|
|
||||||
|
from mcp_familywall.server import create_list, delete_list, get_lists
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def check(label, result, expect_key, expect_val=None):
|
||||||
|
try:
|
||||||
|
obj = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
errors.append(f"FAIL [{label}]: not JSON -- {result!r}")
|
||||||
|
return None
|
||||||
|
if expect_key not in obj:
|
||||||
|
errors.append(f"FAIL [{label}]: missing key '{expect_key}' in {obj}")
|
||||||
|
return None
|
||||||
|
if expect_val is not None and obj[expect_key] != expect_val:
|
||||||
|
errors.append(f"FAIL [{label}]: {expect_key}={obj[expect_key]!r}, expected {expect_val!r}")
|
||||||
|
return None
|
||||||
|
sys.stdout.buffer.write(f"OK [{label}]: {expect_key}={obj.get(expect_key)!r}\n".encode("utf-8"))
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
sys.stdout.buffer.write((str(msg) + "\n").encode("utf-8"))
|
||||||
|
|
||||||
|
# --- Test 1: create SHOPPING_LIST ---
|
||||||
|
r1 = create_list("__claude_test_shop__", "SHOPPING_LIST")
|
||||||
|
log(f"create SHOPPING_LIST = {r1}")
|
||||||
|
o1 = check("create_shopping_list", r1, "created", True)
|
||||||
|
shop_id = o1["id"] if o1 else None
|
||||||
|
|
||||||
|
# --- Test 2: create TODOS ---
|
||||||
|
r2 = create_list("__claude_test_todos__", "TODOS", shared_to_all=True, emoji="\u2705")
|
||||||
|
log(f"create TODOS = {r2}")
|
||||||
|
o2 = check("create_todos", r2, "created", True)
|
||||||
|
todos_id = o2["id"] if o2 else None
|
||||||
|
|
||||||
|
# --- Test 3: verify both appear in get_lists ---
|
||||||
|
lists_json = get_lists()
|
||||||
|
log(f"get_lists (first 300 chars) = {lists_json[:300]}")
|
||||||
|
try:
|
||||||
|
all_lists = json.loads(lists_json)
|
||||||
|
ids = [l["id"] for l in all_lists]
|
||||||
|
if shop_id and shop_id in ids:
|
||||||
|
log(f"OK [get_lists contains shop]: {shop_id}")
|
||||||
|
elif shop_id:
|
||||||
|
errors.append(f"FAIL [get_lists contains shop]: {shop_id} not in lists")
|
||||||
|
if todos_id and todos_id in ids:
|
||||||
|
log(f"OK [get_lists contains todos]: {todos_id}")
|
||||||
|
elif todos_id:
|
||||||
|
errors.append(f"FAIL [get_lists contains todos]: {todos_id} not in lists")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"FAIL [get_lists parse]: {e}")
|
||||||
|
|
||||||
|
# --- Test 4: delete SHOPPING_LIST ---
|
||||||
|
if shop_id:
|
||||||
|
r3 = delete_list(shop_id)
|
||||||
|
log(f"delete SHOPPING_LIST = {r3}")
|
||||||
|
check("delete_shopping_list", r3, "deleted", True)
|
||||||
|
|
||||||
|
# --- Test 5: delete TODOS ---
|
||||||
|
if todos_id:
|
||||||
|
r4 = delete_list(todos_id)
|
||||||
|
log(f"delete TODOS = {r4}")
|
||||||
|
check("delete_todos", r4, "deleted", True)
|
||||||
|
|
||||||
|
# --- Test 6: invalid list_type ---
|
||||||
|
r5 = create_list("bad", "INVALID_TYPE")
|
||||||
|
assert "Error" in r5, f"Expected error for invalid list_type, got: {r5}"
|
||||||
|
log(f"OK [invalid list_type]: {r5}")
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
if errors:
|
||||||
|
log("\nFAILURES:")
|
||||||
|
for e in errors:
|
||||||
|
log(" " + e)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
log("\nAll tests passed.")
|
||||||
Reference in New Issue
Block a user