"""Serveur MCP AntiCoco — accès personnel à HelloFresh, sans noix de coco. Client visé : Hermes (Nous Research), sur le même homelab. Transport streamable-HTTP sur 127.0.0.1:$ANTICOCO_PORT/mcp. Si Hermes n'accepte que le stdio, changer le `transport=` de `mcp.run()` ci-dessous (le reste du code est identique). Les outils sont synchrones : FastMCP les exécute dans un thread worker, ce qui permet d'utiliser l'API *synchrone* de Playwright (auth.py) sans conflit de boucle asyncio. """ from __future__ import annotations import os from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP load_dotenv() from hellofresh import api, auth, filter as hf_filter # noqa: E402 PORT = int(os.environ.get("ANTICOCO_PORT", "9200")) mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT) @mcp.tool() def hf_auth_status() -> dict: """État de la connexion HelloFresh (utilise le token en cache s'il est frais).""" return auth.auth_status() @mcp.tool() def hf_login() -> dict: """S'assure d'être connecté et capture un bearer token frais. En headless (homelab), échoue si la session a expiré → refaire le login local. """ ok = auth.ensure_logged_in() if not ok: return {"logged_in": False, "error": "login non établi (timeout ou session expirée)"} info = auth.capture_token(force=True) return {"logged_in": True, "gateways": info.get("gateways", [])} @mcp.tool() def hf_list_weeks() -> list[dict]: """Liste les semaines de l'abonnement encore modifiables (handle + date livraison).""" with api.HelloFreshClient() as client: weeks = client.get_editable_weeks() return [{"week": w.id, "delivery_date": w.delivery_date, "editable": w.editable, "max_selectable": w.max_selectable} for w in weeks] @mcp.tool() def hf_get_menu(week: str) -> dict: """Toutes les recettes proposées pour une semaine, chacune annotée. Chaque recette porte `contains_excluded` (true si elle contient un ingrédient banni, coco en tête) et `matched_excludes` (quels termes ont matché). """ with api.HelloFreshClient() as client: recipes = client.get_menu(week) hf_filter.annotate(recipes) return { "week": week, "count": len(recipes), "recipes": [r.summary() for r in recipes], } @mcp.tool() def hf_propose(week: str, count: int = 0) -> dict: """Shortlist de recettes SANS ingrédient exclu, classée par préférences. `count=0` renvoie toutes les recettes sûres. Étape « je propose » : rien n'est écrit sur le compte ici — utiliser hf_confirm_selection() ensuite. """ with api.HelloFreshClient() as client: recipes = client.get_menu(week) safe = hf_filter.propose(recipes, count=count or None) excluded = [r.summary() for r in recipes if r.contains_excluded] return { "week": week, "proposed": [r.summary() for r in safe], "excluded_for_coco_etc": excluded, "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", } @mcp.tool() def hf_confirm_selection(week: str, recipe_ids: list[str]) -> dict: """ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation). Garde-fou : refuse une recette contenant un ingrédient exclu. """ with api.HelloFreshClient() as client: menu = client.get_menu(week) hf_filter.annotate(menu) by_id = {r.id: r for r in menu} bad = [rid for rid in recipe_ids if rid in by_id and by_id[rid].contains_excluded] if bad: return { "ok": False, "error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).", "offending_ids": bad, } unknown = [rid for rid in recipe_ids if rid not in by_id] if unknown: return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.", "unknown_ids": unknown} result = client.set_selection(week, recipe_ids) return {"ok": True, "week": week, "selected": recipe_ids, "api_response": result} @mcp.tool() def hf_get_excludes() -> list[str]: """Liste actuelle des ingrédients exclus.""" return hf_filter.load_excludes() @mcp.tool() def hf_add_exclude(term: str) -> list[str]: """Ajoute un ingrédient à exclure. Renvoie la nouvelle liste.""" return hf_filter.add_exclude(term) @mcp.tool() def hf_remove_exclude(term: str) -> list[str]: """Retire un ingrédient de la liste d'exclusion. Renvoie la nouvelle liste.""" return hf_filter.remove_exclude(term) if __name__ == "__main__": mcp.run(transport="streamable-http")