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:
2026-04-17 15:30:26 +02:00
parent 0b56ea92bc
commit bdf9e3c59e
13 changed files with 599 additions and 336 deletions
+7 -6
View File
@@ -42,10 +42,11 @@ htmlcov/
# uv
uv.lock
# Debug and test scripts
debug_*.py
probe_*.py
probe_*.txt
p*_err.txt
test_*.py
# Debug and probe scripts (root-level only)
/debug_*.py
/probe_*.py
/probe_*.txt
/probe_out.txt
/p*_err.txt
/test_*.py
reference/
+179
View File
@@ -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
+16 -42
View File
@@ -24,56 +24,30 @@ und wird in Claude Desktop eingebunden.
## Aktueller Stand
### Implementierte Tools (v0.11.8)
### Version: **v1.0.0** ← aktuell
### Implementierte Tools
| Kategorie | Tools |
|---|---|
| Lesen | `get_circles`, `get_members`, `get_lists`, `get_tasks`, `get_categories`, `get_activities`, `get_meal_plan` |
| Tasks | `create_task`, `update_task`, `toggle_task`, `delete_task`, `clear_list` |
| Listen | `create_list`, `update_list`, `delete_list` |
| 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` |
| 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` |
| 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` |
| 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)
### Historische Meilensteine (kompakt)
- v0.1v0.4: Lesen (Kreise, Listen, Tasks, Kategorien), Schreiben (Tasks, Kategorien)
- v0.5: Listen-Management (create/delete), emoji + color
- v0.6v0.8: Rezeptbox vollständig, Kreismanagement, Rezeptkategorien
- v0.9: Task-Wiederholungen + Erinnerungen (read-only)
- v0.10v0.11: Essensplaner (read + write)
- v1.0: Cleanup, Unified errors, Datumsvalidierung, Partial-Failure-Reporting (Details: CHANGELOG.md)
## Architektur-Entscheidungen
+21
View File
@@ -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.
+54 -38
View File
@@ -1,47 +1,63 @@
# 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
- `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_tasks` -- list tasks in a specific list (includes `category_id`, `due_date`, `assignee_ids`, `recurrency`, `rrule`, `reminder`)
- `get_categories` -- list categories for a list (locale-filtered; custom categories always included; `custom` flag marks user-created ones)
- `get_activities` -- list recent wall activities (author resolved to display name)
- `get_recipes` -- list all family recipes (compact summary: id, name, prep/cook time, serves)
- `get_recipe_box` -- list only real recipes from the recipe box (excludes stubs created for free-text meal entries)
- `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)
| Tool | Description |
|---|---|
| `get_circles` | List all family circles |
| `get_members` | List members of a circle (or all circles) |
| `create_circle` 🔒 | Create a new Family Wall circle |
| `update_circle` 🔒 | Rename a circle (primary circle protected) |
| `add_member_to_circle` 🔒 | Invite a person to a circle by e-mail |
| `delete_circle` 🔒 | Permanently delete a circle and all its content |
### 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
- `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
- `delete_task` -- permanently delete a task
- `clear_list` -- delete all tasks in a list in a single session (bulk delete; optional `only_open=True` to keep completed tasks)
- `create_list` -- create a new task list (`SHOPPING_LIST`, `TODOS`, or `OTHER`; optional `emoji`, `color`, and `circle_id` to target a specific circle)
- `update_list` -- rename a list or change its emoji/color (partial update — omitted fields unchanged; system lists are protected)
- `delete_list` -- permanently delete a list and all its tasks (system lists are protected)
- `create_category` -- create a custom category for a shopping list (with optional icon)
- `delete_category` -- delete a custom category (system categories are protected)
- `like_post` -- like a wall post/activity
- `create_recipe` -- create a new recipe (name, description, ingredients, instructions, prep/cook time, serves, url, category_ids); use `\n` to separate ingredient lines
- `update_recipe` -- update any field of an existing recipe (partial update — omitted fields unchanged; supports `category_ids` list to change categories)
- `delete_recipe` -- permanently delete a recipe (only own recipes)
- `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)
- `add_meal_to_meal_plan` -- add a free-text meal entry (no recipe) to the meal plan for a specific date and meal slot
- `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)
- `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)
- `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)
- `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)
| Tool | Description |
|---|---|
| `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_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) |
| `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) |
| `create_category` 🔒 | Create a custom category (with optional icon) |
| `delete_category` 🔒 | Delete a custom category (system categories protected) |
| `create_task` 🔒 | Create a task (supports category, due date, assignees; use `"Äpfel (5x)"` for quantities) |
| `update_task` 🔒 | Update text, category, due date, assignees, or move to a different list |
| `toggle_task` 🔒 | Mark a task complete or reopen it |
| `delete_task` 🔒 | Permanently delete a task |
| `clear_list` 🔒 | Bulk-delete all tasks in a list (optional `only_open=True` keeps completed tasks) |
| `like_post` 🔒 | Like a wall post/activity |
### Recipe Box
| Tool | Description |
|---|---|
| `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
@@ -107,4 +123,4 @@ Credentials are resolved in this order:
## License
MIT
[MIT](LICENSE) — © 2026 Marcus van Elst
+2
View File
@@ -3,6 +3,8 @@
Erarbeitet durch Browser-Traffic-Analyse und React-Fiber-Analyse (April 2026).
Es gibt keine offizielle API-Dokumentation.
**Stand: v1.0.0 (2026-04-17)**
## Base URL
https://api.familywall.com/api
-157
View File
File diff suppressed because one or more lines are too long
View File
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "mcp-familywall"
version = "0.11.8"
description = "MCP server for Family Wall — read your family's lists and tasks via Claude"
version = "1.0.0"
description = "MCP server for Family Wall — manage your family's circles, lists, tasks, recipes, and meal plan via Claude"
readme = "README.md"
requires-python = ">=3.12"
authors = [
+8 -1
View File
@@ -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
View File
@@ -6,6 +6,7 @@ import contextlib
import json
import logging
import os
import re
import sys
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]:
"""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(
{
"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,
"open": None,
"total": None,
@@ -128,12 +141,12 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
@mcp.tool()
def get_circles():
def get_circles() -> str:
"""Return all Family Wall circles as JSON list of {id, name}."""
try:
raw_circles = _famlistfamily()
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]
return json.dumps(result, ensure_ascii=False, indent=2)
@@ -214,7 +227,7 @@ def get_members(circle_id: str | None = None) -> str:
try:
raw_circles = _famlistfamily()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
result: list[dict[str, Any]] = []
seen_ids: set[str] = set()
@@ -298,7 +311,7 @@ def get_lists(scope: str | None = None) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Build the scope param for taskgettasklists.
# 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:
circles = _famlistfamily()
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)
if matched is None:
circle_names = [c.get("name") for c in circles]
@@ -331,7 +344,7 @@ def get_lists(scope: str | None = None) -> str:
circles = _famlistfamily()
api_scopes = [c["metaId"] for c in circles if "metaId" in c]
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
with FamilyWallClient() as client:
@@ -357,9 +370,9 @@ def get_lists(scope: str | None = None) -> str:
client.logout()
except FamilyWallError as exc:
return f"Error: {exc}"
return _err(str(exc))
except Exception as exc:
return f"Connection error: {exc}"
return _err(f"Connection error: {exc}")
result = []
for item in all_lists:
@@ -388,12 +401,12 @@ def get_lists(scope: str | None = None) -> str:
@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."""
try:
data = _accgetallfamily()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
raw_tasks = _extract_tasks(data)
@@ -530,7 +543,7 @@ def get_categories(list_id: str, locale: str = "de") -> str:
try:
data = _accgetallfamily()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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:
data = _authenticated_call("taskcategoryput", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
cat_obj = data["a00"]["r"]["r"]
@@ -663,7 +676,7 @@ def delete_category(category_id: str) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
cat_obj: dict[str, Any] | None = None
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)
if cat_obj is None:
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")
if can_delete != "true":
@@ -705,9 +718,9 @@ def delete_category(category_id: str) -> str:
client.call("taskcategorydelete", {"id": category_id})
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"deleted": True, "id": category_id, "name": cat_obj.get("name")},
@@ -722,12 +735,12 @@ def delete_category(category_id: str) -> str:
@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."""
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Load member data to resolve author IDs to display names.
# 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)})
client.logout()
except FamilyWallError as exc:
return f"Error: {exc}"
return _err(str(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.
raw_activities: list[dict[str, Any]] | None = None
@@ -881,7 +894,7 @@ def create_task(
try:
data = _authenticated_call("taskcreate2", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Try to extract the new task's metaId from the response.
try:
@@ -939,7 +952,7 @@ def update_task(
JSON success indicator or an error message.
"""
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 (
text is None
@@ -950,8 +963,8 @@ def update_task(
and assignee_ids is None
and list_id is None
):
return (
"Error: At least one of 'text', 'description', 'category_id', 'due_date',"
return _err(
"At least one of 'text', 'description', 'category_id', 'due_date',"
" 'clear_due_date', 'assignee_ids', or 'list_id' must be provided."
)
@@ -975,7 +988,7 @@ def update_task(
try:
_authenticated_call("taskupdate2", params)
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.
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:
_authenticated_call("taskmark", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
return json.dumps(
{"toggled": True, "id": task_id, "complete": complete},
@@ -1036,7 +1049,7 @@ def delete_task(task_id: str) -> str:
"metadelete", {"id": task_id}
) # verified: metadelete uses 'id', not 'metaId'
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)
@@ -1066,12 +1079,12 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
or an error message.
"""
if not list_id.startswith("taskList/"):
return "Error: list_id must start with 'taskList/'"
return _err("list_id must start with 'taskList/'")
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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")
]
deleted_ids: list[str] = []
failed_ids: list[str] = []
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()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"deleted_count": len(tasks_to_delete), "list_id": list_id},
ensure_ascii=False,
indent=2,
)
result: dict[str, Any] = {
"deleted_count": len(deleted_ids),
"list_id": list_id,
}
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.
"""
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:
return "Error: name must not exceed 200 characters."
return _err("name must not exceed 200 characters.")
params: dict[str, Any] = {
"name": name,
@@ -1162,7 +1185,7 @@ def create_list(
try:
data = _authenticated_call("taskcreatelist", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
list_obj = data["a00"]["r"]["r"]
@@ -1218,7 +1241,7 @@ def delete_list(list_id: str) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Derive the owning circle from the list metaId so that secondary-circle
# 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)
if list_obj is None:
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")
if can_delete != "true":
@@ -1269,9 +1292,9 @@ def delete_list(list_id: str) -> str:
client.call("taskdeletelist", del_params)
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"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.
"""
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:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Derive the owning circle from the list metaId (same as delete_list).
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)
if list_obj is None:
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")
if can_update != "true":
@@ -1375,9 +1398,9 @@ def update_list(
resp = client.call("taskupdatelist", upd_params)
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
try:
updated_obj: dict[str, Any] = resp["a00"]["r"]["r"]
@@ -1434,7 +1457,7 @@ def create_circle(name: str) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
with FamilyWallClient() as client:
@@ -1471,9 +1494,9 @@ def create_circle(name: str) -> str:
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"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.
"""
if not name or not name.strip():
return "Error: 'name' must not be empty."
return _err("'name' must not be empty.")
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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")
except (KeyError, TypeError):
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)
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})
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
# Response: a00.r.r = full circle object
try:
@@ -1624,7 +1647,7 @@ def add_member_to_circle(
JSON success indicator or an error message.
"""
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.
if firstname is None:
@@ -1641,7 +1664,7 @@ def add_member_to_circle(
try:
data = _authenticated_call("accinvite", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# On success the server returns the invitation object under a00.r.r.
try:
@@ -1686,7 +1709,7 @@ def delete_circle(circle_id: str) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
circle_name: str = circle_id
try:
@@ -1701,7 +1724,7 @@ def delete_circle(circle_id: str) -> str:
raise TypeError("a00.r.r is not a list")
except (KeyError, TypeError):
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)
if target is None:
@@ -1739,9 +1762,9 @@ def delete_circle(circle_id: str) -> str:
client.call("adminwipefamily", {"scope": circle_id})
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"deleted": True, "id": circle_id, "name": circle_name},
@@ -1795,7 +1818,7 @@ def like_post(post_id: str, like: bool = True) -> str:
try:
data = _authenticated_call("wallmood", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# Extract moodMap from the response to confirm the like was recorded.
try:
@@ -1950,7 +1973,7 @@ def get_recipes() -> str:
try:
raw_recipes = _get_raw_recipes()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
result = [parse_recipe_summary(r) for r in raw_recipes]
return json.dumps(result, ensure_ascii=False, indent=2)
@@ -1976,7 +1999,7 @@ def get_recipe_box() -> str:
try:
raw_recipes = _get_raw_recipes()
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"]
return json.dumps(result, ensure_ascii=False, indent=2)
@@ -2003,11 +2026,11 @@ def get_recipe(recipe_id: str) -> str:
try:
raw_recipes = _get_raw_recipes()
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)
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)
@@ -2067,7 +2090,7 @@ def create_recipe(
try:
data = _authenticated_call("mprecipeput", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
recipe_obj = data["a00"]["r"]["r"]
@@ -2110,7 +2133,7 @@ def delete_recipe(recipe_id: str) -> str:
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
recipe_obj: dict[str, Any] | None = None
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)
if recipe_obj is None:
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")
if can_delete != "true":
@@ -2147,9 +2170,9 @@ def delete_recipe(recipe_id: str) -> str:
client.call("metadelete", {"id": recipe_id})
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
return json.dumps(
{"deleted": True, "id": recipe_id, "name": recipe_obj.get("name")},
@@ -2230,7 +2253,7 @@ def update_recipe(
try:
email, password = get_credentials()
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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)
if current is None:
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")
if can_update != "true":
@@ -2280,9 +2303,9 @@ def update_recipe(
resp = client.call("mprecipeput", params)
client.logout()
except FamilyWallError as exc:
return f"Error: Family Wall API error: {exc}"
return _err(f"Family Wall API error: {exc}")
except Exception as exc:
return f"Error: Connection error: {exc}"
return _err(f"Connection error: {exc}")
try:
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.
"""
if date_err := _validate_date(date_from):
return _err(date_err)
if date_err := _validate_date(date_to):
return _err(date_err)
try:
data = _authenticated_call("mplistinterval", {"from": date_from, "to": date_to})
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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.
"""
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] = {
"recipeId": recipe_id,
@@ -2448,7 +2477,7 @@ def add_recipe_to_meal_plan(
try:
data = _authenticated_call("mpcreateByRecipeId", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
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.
"""
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():
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] = {
"name": name,
@@ -2519,7 +2550,7 @@ def add_meal_to_meal_plan(
try:
data = _authenticated_call("mpcreate", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
# NOTE: mpcreate returns a00.r.r as an *array*, unlike mpcreateByRecipeId
# 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.
"""
if not entry_id.startswith(("dish/", "meal/")):
return (
"Error: entry_id must be a dish or meal metaId "
return _err(
"entry_id must be a dish or meal metaId "
"(e.g. 'dish/16282169_20009811' or 'meal/16282169_1620659')."
)
try:
_authenticated_call("metadelete", {"id": entry_id})
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)
@@ -2620,9 +2651,11 @@ def add_meal_note(
JSON with the new meal entry on success, or an error message.
"""
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:
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}
if note is not None:
@@ -2633,7 +2666,7 @@ def add_meal_note(
try:
data = _authenticated_call("mpmealput", params)
except RuntimeError as exc:
return f"Error: {exc}"
return _err(str(exc))
try:
meal = data["a00"]["r"]["r"]
+98
View File
@@ -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.")
+89
View File
@@ -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.")