- Endpoints découverts: menu (menus-service) + détails batch (recipes/recipes) - get_menu en 2 temps: menu (ids) -> batch détails (ingrédients/allergènes) - Fix faux positifs: exclusion sur ingrédients/allergènes/nom, plus sur les tags (HelloFresh pose un tag interne 'coconut' sur ~la moitié des recettes) - Token mis en cache (pas de navigateur si frais) - endpoints.json versionné (sans secret), semaine optionnelle (défaut = courante) - Testé: 4 recettes coco/85 détectées, shortlist classée, tous les outils MCP OK - set_selection (écriture) reste à découvrir sur un compte avec box active
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""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.
|
|
|
|
`week` vide = semaine courante (format 'YYYY-Www'). Chaque recette porte
|
|
`contains_excluded` (true si ingrédient banni, coco en tête) et `matched_excludes`.
|
|
"""
|
|
w = week or api.current_week()
|
|
with api.HelloFreshClient() as client:
|
|
recipes = client.get_menu(w)
|
|
hf_filter.annotate(recipes)
|
|
return {
|
|
"week": w,
|
|
"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.
|
|
|
|
`week` vide = semaine courante. `count=0` renvoie toutes les recettes sûres.
|
|
Étape « je propose » : rien n'est écrit ici — utiliser hf_confirm_selection() ensuite.
|
|
"""
|
|
w = week or api.current_week()
|
|
with api.HelloFreshClient() as client:
|
|
recipes = client.get_menu(w)
|
|
safe = hf_filter.propose(recipes, count=count or None)
|
|
excluded = [r.summary() for r in recipes if r.contains_excluded]
|
|
return {
|
|
"week": w,
|
|
"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")
|