feat: Gruppe 1 – Projektgerüst, Auth, CLI (v0.1.0)
- pyproject.toml: hatchling build, mcp-familywall entry-point, deps - src/mcp_familywall/__init__.py: version 0.1.0 - src/mcp_familywall/config.py: YAML config loader (schema_version: 1) - src/mcp_familywall/auth.py: keyring credential resolution (FW_EMAIL/FW_PASSWORD → keyring) - src/mcp_familywall/fw_client.py: httpx client, login/logout/call, FW_DEBUG logging - src/mcp_familywall/cli.py: click CLI with setup / check / serve commands - .gitignore, README.md, CLAUDE.md, SPEC.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment files (may contain secrets)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Type checker
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# uv
|
||||||
|
uv.lock
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# mcp-familywall
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Dieses Projekt entwickelt `mcp-familywall` – einen MCP-Server für den Lesezugriff
|
||||||
|
auf Family Wall (familywall.com). Der MCP-Server läuft lokal und wird in Claude Desktop
|
||||||
|
eingebunden.
|
||||||
|
|
||||||
|
|
||||||
|
## Infrastruktur
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Family Wall API** | `https://api.familywall.com/api` |
|
||||||
|
| **Gitea** | `https://gitea.gecheckt.de/marcus/mcp-familywall` |
|
||||||
|
| **Lokaler Code** | `D:\Dev\Projects\mcp-familywall` |
|
||||||
|
| **Sprache** | Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich` |
|
||||||
|
|
||||||
|
|
||||||
|
## Deploy-Workflow (nach jeder Code-Änderung)
|
||||||
|
|
||||||
|
1. Claude Code committet und pusht (bei Berechtigungsfehler: bis zu 2 Retries, je 1s warten)
|
||||||
|
|
||||||
|
## Aktueller Stand
|
||||||
|
|
||||||
|
### Implementierte Tools (v1.0)
|
||||||
|
|
||||||
|
| Kategorie | Tools |
|
||||||
|
|---|---|
|
||||||
|
| Kreise | `get_circles` |
|
||||||
|
| Listen | `get_lists` |
|
||||||
|
| Tasks | `get_tasks` |
|
||||||
|
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- v1.0: Lesezugriff (Kreise + Listen + Tasks) ← aktuell
|
||||||
|
- v2.0: Schreibzugriff (Tasks anlegen, abhaken, löschen)
|
||||||
|
|
||||||
|
|
||||||
|
## Referenzprojekt
|
||||||
|
|
||||||
|
Im Ordner `reference/` liegen Dateien aus einem anderen MCP-Server-Projekt
|
||||||
|
als Orientierung für Struktur und Patterns:
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `reference/pyproject.toml` | Projektstruktur, Dependencies, Entry-Points, Build-System |
|
||||||
|
| `reference/config.py` | Config-Pattern: YAML laden, validieren, speichern |
|
||||||
|
| `reference/auth.py` | Keyring-Integration, Credential-Resolution-Reihenfolge |
|
||||||
|
| `reference/cli.py` | Setup-Wizard, check, serve – CLI-Struktur |
|
||||||
|
|
||||||
|
**Wichtig:** Diese Dateien sind Referenz, kein Copy-Paste. Alle
|
||||||
|
Synology/DSM-spezifischen Teile werden nicht übernommen.
|
||||||
|
Family Wall nutzt ein anderes Auth-Schema – siehe SPEC.md.
|
||||||
|
|
||||||
|
## Architektur-Entscheidungen
|
||||||
|
|
||||||
|
### Session-Strategie
|
||||||
|
Kein Session-Caching. Jeder Tool-Call führt Login → API-Call → Logout durch.
|
||||||
|
Credentials liegen im OS Keyring (nur `email` + `password`), kein `session_id`.
|
||||||
|
|
||||||
|
### Kreise (Scopes)
|
||||||
|
Family Wall kennt mehrere Kreise (z.B. Familie, erweiterter Familienkreis).
|
||||||
|
`get_lists` unterstützt optionalen `scope`-Parameter zur Filterung.
|
||||||
|
Ohne `scope` werden alle Kreise zurückgegeben.
|
||||||
|
|
||||||
|
### Listen-Namen
|
||||||
|
Systembezeichnungen (z.B. `SYS-CAT-SHOPPINGLIST`) werden in deutsche
|
||||||
|
Klarnamen übersetzt. Mapping-Tabelle in `modules/lists.py`.
|
||||||
|
|
||||||
|
|
||||||
|
## Claude Code – Implementierungsregeln
|
||||||
|
|
||||||
|
- Keine destruktiven Operationen in v1.0 → kein Confirmation-Pattern erforderlich
|
||||||
|
- Fehlerbehandlung: API-Fehler als verständliche Meldung zurückgeben, keine Stacktraces
|
||||||
|
- Keine Secrets in stderr-Ausgaben (Passwort bei Debug-Logging maskieren)
|
||||||
|
- Type Hints und Docstrings konsequent verwenden
|
||||||
|
- Formatter: `ruff format`, Linter: `ruff check`, Tests: `pytest`
|
||||||
|
- Alle Texte (Docstrings, Kommentare, README): Englisch
|
||||||
|
- Debug-Logging via `FW_DEBUG=1` Umgebungsvariable
|
||||||
|
- Nach jeder Aufgabe: git commit + push. Bei Berechtigungsfehler: 1s warten, bis zu 2 Retries
|
||||||
|
- .gitignore eigenständig pflegen (Credentials, __pycache__, .venv, .env, *.pyc etc.)
|
||||||
|
- README.md im Projekt-Root pflegen und bei jedem Aufruf aktualisieren
|
||||||
|
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
### Gruppe 1 – Projektgerüst + Auth + CLI ✦ Prio: hoch
|
||||||
|
|
||||||
|
- `pyproject.toml`: Package `mcp-familywall`, Entry-Point `mcp-familywall`
|
||||||
|
- `src/mcp_familywall/config.py`: `~/.config/mcp-familywall/config.yaml`,
|
||||||
|
schema_version 1
|
||||||
|
- `src/mcp_familywall/auth.py`: Keyring-Service `mcp-familywall`,
|
||||||
|
Keys: `email`, `password`. Credential-Resolution:
|
||||||
|
1. Umgebungsvariablen `FW_EMAIL`, `FW_PASSWORD`
|
||||||
|
2. OS Keyring
|
||||||
|
- `src/mcp_familywall/fw_client.py`: HTTP-Client mit `httpx`.
|
||||||
|
Methoden: `login()`, `logout()`, `call(endpoint, params)`.
|
||||||
|
Debug-Logging wenn `FW_DEBUG=1` (Passwort maskieren).
|
||||||
|
- `src/mcp_familywall/cli.py`:
|
||||||
|
- `setup`: fragt E-Mail + Passwort, führt Login/Logout durch,
|
||||||
|
speichert Credentials im Keyring, gibt Claude-Desktop-Snippet aus
|
||||||
|
- `check`: testet Auth und API-Erreichbarkeit
|
||||||
|
- `serve`: startet MCP-Server
|
||||||
|
|
||||||
|
Config-Datei enthält nur:
|
||||||
|
```yaml
|
||||||
|
schema_version: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gruppe 2 – MCP Tools ✦ Prio: hoch
|
||||||
|
|
||||||
|
`src/mcp_familywall/server.py` + `src/mcp_familywall/modules/lists.py`
|
||||||
|
|
||||||
|
**`get_circles`**
|
||||||
|
- Ruft `famlistfamily` auf
|
||||||
|
- Gibt alle Kreise zurück: `id`, `name`
|
||||||
|
- Confirmation: nein
|
||||||
|
|
||||||
|
**`get_lists`**
|
||||||
|
- Signatur: `get_lists(scope: str = None) -> str`
|
||||||
|
- Ruft `accgetallfamily` auf (Parameter: `a01call=taskcategorysync`, `a02call=tasksync`)
|
||||||
|
- Gibt zurück: `id` (metaId), `name` (übersetzt), `type`, offene Einträge,
|
||||||
|
Gesamteinträge, Kreis-Name
|
||||||
|
- Ohne `scope`: alle Kreise; mit `scope`: nur dieser Kreis
|
||||||
|
- Confirmation: nein
|
||||||
|
|
||||||
|
**`get_tasks`**
|
||||||
|
- Signatur: `get_tasks(list_id: str, only_open: bool = True) -> str`
|
||||||
|
- `list_id`: metaId aus `get_lists`
|
||||||
|
- Gibt zurück: `id`, `text`, `description`, `completed`
|
||||||
|
- Confirmation: nein
|
||||||
|
|
||||||
|
|
||||||
|
## Test-Credentials (nur für Entwicklung)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| E-Mail | `marcus@gecheckt.de` |
|
||||||
|
| Passwort | `Lasdas1234` |
|
||||||
|
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
Marcus ist Senior Software Engineer (Java, Jakarta EE).
|
||||||
|
Präferenz: State-of-the-Art, Best Practices, saubere Architektur.
|
||||||
|
Automatisierung spart Zeit für die Familie. 🌱
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# mcp-familywall
|
||||||
|
|
||||||
|
MCP server for [Family Wall](https://www.familywall.com) -- read your family's circles, lists, and tasks directly from Claude.
|
||||||
|
|
||||||
|
## Features (v1.0 -- read-only)
|
||||||
|
|
||||||
|
- `get_circles` -- list all family circles
|
||||||
|
- `get_lists` -- list all task lists (optionally filtered by circle)
|
||||||
|
- `get_tasks` -- list tasks in a specific list
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
- A Family Wall account
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install mcp-familywall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Run the interactive setup wizard once to store your credentials securely in the OS keyring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcp-familywall setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Prompt for your Family Wall email and password
|
||||||
|
2. Verify the credentials against the API
|
||||||
|
3. Store them in the OS keyring
|
||||||
|
4. Print a Claude Desktop configuration snippet
|
||||||
|
|
||||||
|
## Claude Desktop configuration
|
||||||
|
|
||||||
|
Add the printed snippet to your `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"familywall": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-familywall", "serve"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcp-familywall check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debug logging
|
||||||
|
|
||||||
|
Set `FW_DEBUG=1` to log full request/response bodies to stderr (passwords are masked):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FW_DEBUG=1 mcp-familywall check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Credentials are resolved in this order:
|
||||||
|
|
||||||
|
1. Environment variables `FW_EMAIL` and `FW_PASSWORD`
|
||||||
|
2. OS keyring (set by `mcp-familywall setup`)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Family Wall API – Spezifikation
|
||||||
|
|
||||||
|
Erarbeitet durch Browser-Traffic-Analyse (April 2026).
|
||||||
|
Es gibt keine offizielle API-Dokumentation.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
https://api.familywall.com/api
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
### Login
|
||||||
|
POST https://api.familywall.com/api/log2in
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
**Request-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `identifier` | E-Mail-Adresse |
|
||||||
|
| `password` | Passwort |
|
||||||
|
| `type` | zu verifizieren beim ersten echten Login-Call (vermutlich `"email"`) |
|
||||||
|
| `clientId` | weglassen |
|
||||||
|
| `clientSecret` | weglassen |
|
||||||
|
| `generateAutologinToken` | weglassen |
|
||||||
|
| `countryCode` | weglassen |
|
||||||
|
|
||||||
|
**Response (Erfolg):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "r": { "r": <SessionObject> } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Fehler):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ex": { "ex": <ErrorObject> } }
|
||||||
|
{ "un": { "un": <ErrorObject> } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server setzt nach erfolgreichem Login ein Session-Cookie:
|
||||||
|
Set-Cookie: JSESSIONID=<session-id>
|
||||||
|
|
||||||
|
### Folgecalls (nach Login)
|
||||||
|
|
||||||
|
Alle API-Calls nach dem Login benötigen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Cookie** | `JSESSIONID=<session-id>` |
|
||||||
|
| **Header** | `Tokencsrf: <session-id>` (identisch zur JSESSIONID) |
|
||||||
|
| **Content-Type** | `application/x-www-form-urlencoded` |
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
POST https://api.familywall.com/api/log2out
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
Keine Parameter. Session wird serverseitig invalidiert.
|
||||||
|
|
||||||
|
### Session-Strategie
|
||||||
|
|
||||||
|
Kein Session-Caching. Jeder MCP-Tool-Call führt folgende Sequenz aus:
|
||||||
|
|
||||||
|
POST /api/log2in → Session-ID
|
||||||
|
POST /api/<endpoint> → Nutzdaten
|
||||||
|
POST /api/log2out → Session invalidieren
|
||||||
|
|
||||||
|
|
||||||
|
Credentials (E-Mail + Passwort) werden einmalig via `mcp-familywall setup`
|
||||||
|
im OS Keyring gespeichert (Keys: `email`, `password`). Kein Keyring-Eintrag
|
||||||
|
für `session_id`.
|
||||||
|
|
||||||
|
## Bekannte Endpoints
|
||||||
|
|
||||||
|
### `famlistfamily` – Kreise abrufen
|
||||||
|
POST https://api.familywall.com/api/famlistfamily
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
**Body-Parameter:** keine (zu verifizieren beim ersten echten Call)
|
||||||
|
|
||||||
|
**Response-Struktur:** zu verifizieren beim ersten echten Call
|
||||||
|
|
||||||
|
### `accgetallfamily` – Listen + Tasks abrufen
|
||||||
|
POST https://api.familywall.com/api/accgetallfamily
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
**Body-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `a01call` | `"taskcategorysync"` |
|
||||||
|
| `a02call` | `"tasksync"` |
|
||||||
|
|
||||||
|
Hinweis: `partnerScope`, `a03call`, `a03id`, `withStateBean` werden
|
||||||
|
weggelassen. Falls der Server sie erfordert, beim ersten echten Call
|
||||||
|
nachbessern.
|
||||||
|
|
||||||
|
**Response-Struktur (relevant für v1.0):**
|
||||||
|
a00.r.r[] → Listen (taskcategorysync)
|
||||||
|
.metaId → eindeutige Listen-ID
|
||||||
|
.name → Name (ggf. Systembezeichnung, s.u.)
|
||||||
|
.taskListType → Typ der Liste
|
||||||
|
.remainingTaskNumber → offene Einträge (String)
|
||||||
|
.totalTaskNumber → Gesamteinträge (String)
|
||||||
|
.<Kreis-Feld> → zu verifizieren beim ersten echten Call
|
||||||
|
a02.r.r.updatedCreated[] → Tasks (tasksync)
|
||||||
|
.metaId → eindeutige Task-ID
|
||||||
|
.text → Aufgabentext
|
||||||
|
.description → optionale Beschreibung
|
||||||
|
.taskListId → Zugehörigkeit zur Liste (= metaId der Liste)
|
||||||
|
.complete → "true" / "false" (String, nicht Boolean!)
|
||||||
|
|
||||||
|
## Systembezeichnungen für Listen-Namen
|
||||||
|
|
||||||
|
Bekannte Systembezeichnungen werden deutsch übersetzt:
|
||||||
|
|
||||||
|
| Systembezeichnung | Deutsch |
|
||||||
|
|---|---|
|
||||||
|
| `SYS-CAT-SHOPPINGLIST` | `Einkaufsliste` |
|
||||||
|
|
||||||
|
Unbekannte Bezeichnungen werden unverändert zurückgegeben.
|
||||||
|
Mapping-Tabelle bei Bedarf erweitern.
|
||||||
|
|
||||||
|
## Debug-Logging
|
||||||
|
|
||||||
|
Wenn die Umgebungsvariable `FW_DEBUG=1` gesetzt ist, loggt `fw_client.py`
|
||||||
|
vollständige Request-Bodies und Responses nach stderr. Dient zur Verifikation
|
||||||
|
offener Punkte (z.B. `type`-Parameter beim Login, Kreis-Felder in Response).
|
||||||
|
|
||||||
|
**Wichtig:** Keine Secrets in Debug-Ausgaben (Passwort maskieren).
|
||||||
|
|
||||||
|
## Noch zu verifizieren
|
||||||
|
|
||||||
|
- Exakter Wert für `type`-Parameter beim Login
|
||||||
|
- Response-Struktur von `famlistfamily` (Kreise)
|
||||||
|
- Kreis-Zuordnung in `accgetallfamily`-Response
|
||||||
|
- Ob `partnerScope` / `withStateBean` benötigt werden
|
||||||
|
- Session-Lebensdauer (irrelevant da kein Caching)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mcp-familywall"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for Family Wall — read your family's lists and tasks via Claude"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [
|
||||||
|
{ name = "Marcus van Elst" },
|
||||||
|
]
|
||||||
|
keywords = ["mcp", "familywall", "family", "tasks", "lists"]
|
||||||
|
dependencies = [
|
||||||
|
"mcp[cli]>=1.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"keyring>=25.0",
|
||||||
|
"click>=8.0",
|
||||||
|
"rich>=13.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"ruff>=0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcp-familywall = "mcp_familywall.cli:app"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mcp_familywall"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TCH"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Credential resolution for Family Wall.
|
||||||
|
|
||||||
|
Credential resolution order:
|
||||||
|
1. Environment variables FW_EMAIL, FW_PASSWORD
|
||||||
|
2. OS keyring (service: 'mcp-familywall', keys: 'email', 'password')
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KEYRING_SERVICE = "mcp-familywall"
|
||||||
|
|
||||||
|
|
||||||
|
def get_credentials() -> tuple[str, str]:
|
||||||
|
"""Resolve Family Wall credentials.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Environment variables FW_EMAIL / FW_PASSWORD
|
||||||
|
2. OS keyring
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (email, password).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If credentials cannot be resolved from any source.
|
||||||
|
"""
|
||||||
|
email: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
# 1. Environment variables (highest priority)
|
||||||
|
email = os.environ.get("FW_EMAIL")
|
||||||
|
if email:
|
||||||
|
logger.debug("Email from env var FW_EMAIL: %s", email)
|
||||||
|
|
||||||
|
password = os.environ.get("FW_PASSWORD")
|
||||||
|
if password:
|
||||||
|
logger.debug("Password from env var FW_PASSWORD")
|
||||||
|
|
||||||
|
# 2. OS keyring
|
||||||
|
if not email or not password:
|
||||||
|
try:
|
||||||
|
import keyring
|
||||||
|
|
||||||
|
kr_email = keyring.get_password(KEYRING_SERVICE, "email")
|
||||||
|
kr_password = keyring.get_password(KEYRING_SERVICE, "password")
|
||||||
|
|
||||||
|
if kr_email and not email:
|
||||||
|
email = kr_email
|
||||||
|
logger.debug("Email from keyring: %s", email)
|
||||||
|
if kr_password and not password:
|
||||||
|
password = kr_password
|
||||||
|
logger.debug("Password from keyring")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Keyring not available or could not be read.")
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
msg = (
|
||||||
|
"No credentials found. Run 'mcp-familywall setup' to store credentials "
|
||||||
|
"in the OS keyring, or set FW_EMAIL and FW_PASSWORD environment variables."
|
||||||
|
)
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
logger.debug("Credentials resolved: email=%s, has_password=yes", email)
|
||||||
|
return email, password
|
||||||
|
|
||||||
|
|
||||||
|
def store_credentials(email: str, password: str) -> bool:
|
||||||
|
"""Store credentials in the OS keyring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Family Wall email address.
|
||||||
|
password: Family Wall password.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if stored successfully, False if keyring is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import keyring
|
||||||
|
from keyring.errors import KeyringError
|
||||||
|
|
||||||
|
keyring.set_password(KEYRING_SERVICE, "email", email)
|
||||||
|
keyring.set_password(KEYRING_SERVICE, "password", password)
|
||||||
|
logger.debug("Credentials stored in keyring (service=%s)", KEYRING_SERVICE)
|
||||||
|
return True
|
||||||
|
except (ImportError, KeyringError, OSError) as exc:
|
||||||
|
logger.debug("Failed to store credentials in keyring: %s", exc)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""CLI entry point for mcp-familywall.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- setup : Interactive credential setup; stores email+password in OS keyring.
|
||||||
|
- check : Validate stored credentials against the Family Wall API.
|
||||||
|
- serve : Start the MCP server (launched by Claude Desktop).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from mcp_familywall import __version__
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True)
|
||||||
|
@click.version_option(__version__, "-v", "--version", prog_name="mcp-familywall")
|
||||||
|
@click.pass_context
|
||||||
|
def app(ctx: click.Context) -> None:
|
||||||
|
"""mcp-familywall — MCP server for Family Wall (read-only)."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
click.echo(ctx.get_help())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def setup() -> None:
|
||||||
|
"""Interactive credential setup.
|
||||||
|
|
||||||
|
Prompts for email and password, verifies them against the Family Wall API,
|
||||||
|
stores them in the OS keyring, and prints a Claude Desktop config snippet.
|
||||||
|
"""
|
||||||
|
from mcp_familywall.auth import store_credentials
|
||||||
|
from mcp_familywall.config import save_config
|
||||||
|
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
|
||||||
|
|
||||||
|
click.echo("mcp-familywall setup\n")
|
||||||
|
|
||||||
|
email = click.prompt("Family Wall email")
|
||||||
|
password = click.prompt("Family Wall password", hide_input=True)
|
||||||
|
|
||||||
|
# Verify credentials
|
||||||
|
click.echo("\nVerifying credentials...")
|
||||||
|
try:
|
||||||
|
with FamilyWallClient() as client:
|
||||||
|
client.login(email, password)
|
||||||
|
client.logout()
|
||||||
|
except FamilyWallError as exc:
|
||||||
|
click.echo(click.style(f"Login failed: {exc}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(click.style("Login successful!", fg="green"))
|
||||||
|
|
||||||
|
# Store credentials
|
||||||
|
ok = store_credentials(email, password)
|
||||||
|
if ok:
|
||||||
|
click.echo("Credentials stored in OS keyring.")
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
click.style("Keyring not available.", fg="yellow")
|
||||||
|
+ " Set environment variables instead:\n"
|
||||||
|
f" FW_EMAIL={email}\n"
|
||||||
|
" FW_PASSWORD=<your-password>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure config file exists
|
||||||
|
save_config()
|
||||||
|
|
||||||
|
# Print Claude Desktop snippet
|
||||||
|
_emit_claude_desktop_snippet()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def check() -> None:
|
||||||
|
"""Validate stored credentials against the Family Wall API."""
|
||||||
|
from mcp_familywall.auth import get_credentials
|
||||||
|
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
|
||||||
|
|
||||||
|
click.echo("Checking Family Wall credentials...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
email, password = get_credentials()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(f"Email: {email}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with FamilyWallClient() as client:
|
||||||
|
client.login(email, password)
|
||||||
|
client.logout()
|
||||||
|
except FamilyWallError as exc:
|
||||||
|
click.echo(click.style(f"Authentication failed: {exc}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(click.style("Authentication successful!", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# serve
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve() -> None:
|
||||||
|
"""Start the MCP server (launched by Claude Desktop)."""
|
||||||
|
from mcp_familywall.server import create_server
|
||||||
|
|
||||||
|
server = create_server()
|
||||||
|
server.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_claude_desktop_snippet() -> None:
|
||||||
|
"""Print a Claude Desktop JSON config snippet."""
|
||||||
|
uvx_path = shutil.which("uvx") or "<path-to-uvx>"
|
||||||
|
|
||||||
|
snippet = {
|
||||||
|
"mcpServers": {
|
||||||
|
"familywall": {
|
||||||
|
"command": uvx_path,
|
||||||
|
"args": ["mcp-familywall", "serve"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
click.echo("\nAdd this to your Claude Desktop config (claude_desktop_config.json):\n")
|
||||||
|
click.echo(json.dumps(snippet, indent=2))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""YAML config loading and validation.
|
||||||
|
|
||||||
|
Config path: ~/.config/mcp-familywall/config.yaml
|
||||||
|
Schema: {schema_version: 1}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CURRENT_SCHEMA_VERSION = 1
|
||||||
|
CONFIG_DIR = Path.home() / ".config" / "mcp-familywall"
|
||||||
|
CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
||||||
|
|
||||||
|
_DEFAULT_CONFIG: dict[str, Any] = {"schema_version": CURRENT_SCHEMA_VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict[str, Any]:
|
||||||
|
"""Load and validate the config file.
|
||||||
|
|
||||||
|
Returns the config dict. Creates a default config if none exists.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schema_version does not match the expected version.
|
||||||
|
"""
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
logger.debug("No config found at %s; using defaults", CONFIG_PATH)
|
||||||
|
return dict(_DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
logger.debug("Loading config from %s", CONFIG_PATH)
|
||||||
|
raw_text = CONFIG_PATH.read_text(encoding="utf-8")
|
||||||
|
raw: dict[str, Any] = yaml.safe_load(raw_text) or {}
|
||||||
|
|
||||||
|
_validate(raw)
|
||||||
|
logger.debug("Config loaded: schema_version=%s", raw.get("schema_version"))
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Save a config dict to disk (or write defaults if None).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Config dict to save. Defaults to minimal default config.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = dict(_DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
_validate(config)
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
header = "# Generated by mcp-familywall setup\n"
|
||||||
|
CONFIG_PATH.write_text(header + yaml.dump(config, default_flow_style=False), encoding="utf-8")
|
||||||
|
logger.debug("Config written to %s", CONFIG_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(config: dict[str, Any]) -> None:
|
||||||
|
"""Validate config dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schema_version is missing or wrong.
|
||||||
|
"""
|
||||||
|
version = config.get("schema_version")
|
||||||
|
if version != CURRENT_SCHEMA_VERSION:
|
||||||
|
msg = (
|
||||||
|
f"Config schema_version is {version!r}, "
|
||||||
|
f"but this server expects {CURRENT_SCHEMA_VERSION}."
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Family Wall HTTP client.
|
||||||
|
|
||||||
|
Session strategy: Login → API call → Logout per MCP tool call. No session caching.
|
||||||
|
|
||||||
|
Debug logging is enabled when the environment variable FW_DEBUG=1 is set.
|
||||||
|
Passwords are always masked in debug output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://api.familywall.com/api"
|
||||||
|
|
||||||
|
_debug = os.environ.get("FW_DEBUG") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_log(label: str, data: Any) -> None:
|
||||||
|
"""Write debug info to stderr when FW_DEBUG=1."""
|
||||||
|
if _debug:
|
||||||
|
print(f"[FW_DEBUG] {label}: {data}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_password(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a copy of data with the password field masked."""
|
||||||
|
masked = dict(data)
|
||||||
|
if "password" in masked:
|
||||||
|
masked["password"] = "***"
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyWallError(Exception):
|
||||||
|
"""Raised when the Family Wall API returns an error response."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, response_data: Any = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.response_data = response_data
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyWallClient:
|
||||||
|
"""Synchronous HTTP client for the Family Wall API.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
client = FamilyWallClient()
|
||||||
|
client.login(email, password)
|
||||||
|
data = client.call("accgetallfamily", {"a01call": "taskcategorysync"})
|
||||||
|
client.logout()
|
||||||
|
|
||||||
|
Or use as a context manager::
|
||||||
|
|
||||||
|
with FamilyWallClient() as client:
|
||||||
|
client.login(email, password)
|
||||||
|
data = client.call("famlistfamily")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._http: httpx.Client = httpx.Client(timeout=30)
|
||||||
|
self._session_id: str | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> FamilyWallClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def login(self, email: str, password: str) -> None:
|
||||||
|
"""Authenticate with Family Wall and store the session ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Family Wall account email.
|
||||||
|
password: Family Wall account password.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FamilyWallError: If authentication fails.
|
||||||
|
httpx.HTTPError: On network-level errors.
|
||||||
|
"""
|
||||||
|
endpoint = f"{BASE_URL}/log2in"
|
||||||
|
payload = {
|
||||||
|
"identifier": email,
|
||||||
|
"password": password,
|
||||||
|
"type": "email",
|
||||||
|
}
|
||||||
|
_debug_log("LOGIN request", _mask_password(payload))
|
||||||
|
|
||||||
|
response = self._http.post(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body: dict[str, Any] = response.json()
|
||||||
|
_debug_log("LOGIN response", body)
|
||||||
|
|
||||||
|
if "ex" in body or "un" in body:
|
||||||
|
error_data = body.get("ex", body.get("un"))
|
||||||
|
msg = f"Login failed: {error_data}"
|
||||||
|
raise FamilyWallError(msg, response_data=body)
|
||||||
|
|
||||||
|
if "r" not in body:
|
||||||
|
raise FamilyWallError("Login failed: unexpected response format", response_data=body)
|
||||||
|
|
||||||
|
# Extract JSESSIONID from the Set-Cookie header
|
||||||
|
session_id = response.cookies.get("JSESSIONID")
|
||||||
|
if not session_id:
|
||||||
|
raise FamilyWallError("Login succeeded but no JSESSIONID cookie returned.")
|
||||||
|
|
||||||
|
self._session_id = session_id
|
||||||
|
logger.debug("Logged in; session acquired")
|
||||||
|
|
||||||
|
# Configure the HTTP client to send the session cookie and CSRF token on all future calls
|
||||||
|
self._http.cookies.set("JSESSIONID", session_id)
|
||||||
|
self._http.headers.update({"Tokencsrf": session_id})
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
"""Invalidate the current session server-side.
|
||||||
|
|
||||||
|
Silently ignores errors — the session will eventually expire anyway.
|
||||||
|
"""
|
||||||
|
if not self._session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
endpoint = f"{BASE_URL}/log2out"
|
||||||
|
_debug_log("LOGOUT request", {})
|
||||||
|
try:
|
||||||
|
response = self._http.post(
|
||||||
|
endpoint,
|
||||||
|
data={},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
_debug_log("LOGOUT response", response.text)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.debug("Logout request failed (session may already be expired): %s", exc)
|
||||||
|
finally:
|
||||||
|
self._session_id = None
|
||||||
|
self._http.cookies.clear()
|
||||||
|
if "Tokencsrf" in self._http.headers:
|
||||||
|
del self._http.headers["Tokencsrf"]
|
||||||
|
|
||||||
|
def call(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""Call a Family Wall API endpoint.
|
||||||
|
|
||||||
|
Must be called after a successful :meth:`login`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint name (e.g. ``"famlistfamily"``).
|
||||||
|
params: Optional form parameters to send.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response body.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FamilyWallError: If not logged in, or if the API returns an error.
|
||||||
|
httpx.HTTPError: On network-level errors.
|
||||||
|
"""
|
||||||
|
if not self._session_id:
|
||||||
|
raise FamilyWallError("Not logged in. Call login() first.")
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/{endpoint}"
|
||||||
|
payload = params or {}
|
||||||
|
_debug_log(f"CALL {endpoint} request", payload)
|
||||||
|
|
||||||
|
response = self._http.post(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body: dict[str, Any] = response.json()
|
||||||
|
_debug_log(f"CALL {endpoint} response", body)
|
||||||
|
|
||||||
|
if "ex" in body or "un" in body:
|
||||||
|
error_data = body.get("ex", body.get("un"))
|
||||||
|
msg = f"API error from {endpoint!r}: {error_data}"
|
||||||
|
raise FamilyWallError(msg, response_data=body)
|
||||||
|
|
||||||
|
return body
|
||||||
Reference in New Issue
Block a user