Outils MCP async (fix Playwright/asyncio) + hf_account_info + auth vérifiée

- server.py : outils passés en async + déport thread (anyio.to_thread.run_sync).
  Le SDK mcp 1.27.2 appelle les outils sync directement dans la boucle asyncio,
  ce qui cassait l'API sync de Playwright. Transport configurable via
  ANTICOCO_TRANSPORT (défaut streamable-http, stdio pour Claude Code local).
- api.py : nouvelle méthode account_info() (client, abonnement, adresse,
  prochaine livraison) + outil MCP hf_account_info (lecture seule).
- auth.py : auth_status() valide désormais le token par un vrai appel API
  (200 vs 401) au lieu de supposer "token présent = connecté", et n'ouvre plus
  de navigateur. _is_logged_in() utilise un signal positif (cookie apiV2Auth
  non expiré) au lieu de l'absence de champ mot de passe. Supprime les faux
  positifs "connecté" sur session morte (important pour le homelab/Hermes).
This commit is contained in:
jerem
2026-06-18 11:31:56 +02:00
parent 5d3899fdfb
commit e37a27cc1a
3 changed files with 206 additions and 75 deletions

View File

@@ -146,6 +146,65 @@ class HelloFreshClient:
"customer_id": str(cust.get("id", "")), "customer_id": str(cust.get("id", "")),
"sku": str(prod.get("sku", ""))} "sku": str(prod.get("sku", ""))}
def account_info(self) -> dict[str, Any]:
"""Résumé lisible du compte : client, abonnement, adresse, prochaine livraison."""
ep = self._ep.get("subscriptions")
if not ep:
raise EndpointsNotConfigured("Endpoint 'subscriptions' manquant.")
data = self._request("GET", ep, params={"country": self._country}).json()
items = data.get("items", []) if isinstance(data, dict) else (data or [])
if not items:
raise RuntimeError("Aucun abonnement actif sur ce compte.")
sub = items[0]
cust = sub.get("customer") or {}
ptype = sub.get("productType") or {}
prod = sub.get("product") or {}
ship = sub.get("shippingAddress") or {}
def _addr(a: dict[str, Any]) -> dict[str, Any]:
return {
"name": " ".join(p for p in (a.get("firstName"), a.get("lastName")) if p),
"address": a.get("address1"),
"postcode": a.get("postcode"),
"city": a.get("city"),
"country": (a.get("country") or {}).get("iso2Code"),
"phone": a.get("phone"),
}
unit_price = prod.get("unitPrice")
return {
"customer": {
"id": cust.get("id"),
"email": cust.get("email"),
"first_name": cust.get("firstName"),
"last_name": cust.get("lastName"),
"locale": cust.get("locale"),
"loyalty_points": (cust.get("loyalty") or {}).get("value"),
},
"subscription": {
"id": sub.get("id"),
"active": sub.get("isActive"),
"paused_at": sub.get("pausedAt"),
"canceled_at": sub.get("canceledAt"),
"blocked": sub.get("isBlocked"),
"sku": prod.get("sku") or ptype.get("handle"),
"product_name": ptype.get("productName"),
"meals": (ptype.get("specs") or {}).get("meals"),
"people": (ptype.get("specs") or {}).get("size"),
"box_price_eur": unit_price / 100 if isinstance(unit_price, int) else None,
"preset": sub.get("preset"),
"delivery_weekday": sub.get("deliveryWeekday"),
"delivery_interval": sub.get("deliveryInterval"),
"payment_method": sub.get("paymentMethod"),
},
"shipping_address": _addr(ship),
"next_delivery": {
"week": sub.get("nextDeliveryWeek"),
"date": sub.get("nextDelivery"),
"cutoff": sub.get("nextCutoffDate"),
},
}
def _menu_courses(self, week: str) -> list[dict]: def _menu_courses(self, week: str) -> list[dict]:
"""Courses bruts du menu (chacun : index + recipe). Base de l'écriture.""" """Courses bruts du menu (chacun : index + recipe). Base de l'écriture."""
params = { params = {

View File

@@ -20,6 +20,7 @@ import time
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
import httpx
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -32,6 +33,9 @@ BASE_URL = "https://www.hellofresh.fr"
# Page qui déclenche des appels gateway authentifiés (menu de la semaine). # Page qui déclenche des appels gateway authentifiés (menu de la semaine).
MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu" MENU_PAGE = f"{BASE_URL}/my-account/deliveries/menu"
ACCOUNT_PAGE = f"{BASE_URL}/my-account" ACCOUNT_PAGE = f"{BASE_URL}/my-account"
# Endpoint gateway authentifié, léger, utilisé pour VÉRIFIER qu'un token est réellement
# accepté (200) ou non (401). Vérité de terrain, contre les faux positifs de détection UI.
VALIDATE_URL = f"{BASE_URL}/gw/api/customers/me/subscriptions"
ATTENTE_LOGIN_S = 180 # temps laissé pour un login manuel (captcha / 2FA) ATTENTE_LOGIN_S = 180 # temps laissé pour un login manuel (captcha / 2FA)
TOKEN_TTL_S = 30 * 60 # on rafraîchit le token au-delà de 30 min par prudence TOKEN_TTL_S = 30 * 60 # on rafraîchit le token au-delà de 30 min par prudence
@@ -56,12 +60,20 @@ def _is_gateway_request(url: str) -> bool:
def _is_logged_in(page) -> bool: def _is_logged_in(page) -> bool:
"""Pas connecté = un champ mot de passe est visible (page de login). """Connecté = cookie d'auth `apiV2Auth` présent avec un access_token non expiré.
Détection volontairement indépendante de la locale (sélecteur CSS, pas de texte). Signal POSITIF (cookie réel) plutôt que l'absence d'un champ mot de passe : cette
dernière produisait des faux positifs en headless (page de login non rendue/redirigée,
bannière cookies) → la session paraissait valide alors qu'elle ne l'était pas.
""" """
try: try:
return page.locator('input[type="password"]').count() == 0 for c in page.context.cookies():
if c.get("name") == "apiV2Auth":
data = json.loads(urllib.parse.unquote(c.get("value", "")))
tok = data.get("access_token")
if tok and _jwt_exp(tok) > time.time() + 60:
return True
return False
except Exception: except Exception:
return False return False
@@ -284,16 +296,43 @@ def get_token(force: bool = False) -> str:
return capture_token(force=force)["token"] return capture_token(force=force)["token"]
def _token_works(token: str) -> bool:
"""Vrai si le token est réellement accepté par l'API gateway (appel léger, 200 vs 401).
Vérité de terrain : c'est ce qui empêche de déclarer « connecté » une session morte.
En cas d'erreur réseau (résultat indéterminable), renvoie False par prudence.
"""
try:
resp = httpx.get(
VALIDATE_URL,
params={"country": "FR"},
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=15.0,
)
except Exception:
return False
return resp.status_code == 200
def auth_status() -> dict: def auth_status() -> dict:
"""État de connexion sans ouvrir de fenêtre si un token en cache est encore frais.""" """État de connexion VÉRIFIÉ par un vrai appel API — pas de faux positif.
N'ouvre jamais de navigateur : on prend le meilleur token disponible (cache frais,
puis cookie storage_state) et on le valide contre l'API. Si aucun token n'est
exploitable ou s'il est rejeté, renvoie logged_in=False avec une consigne de re-login.
"""
cached = _read_token_cache() cached = _read_token_cache()
if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S: if cached and (time.time() - cached.get("captured_at", 0)) < TOKEN_TTL_S:
age = int(time.time() - cached["captured_at"]) token = cached.get("token")
return {"logged_in": True, "source": "cache", "token_age_s": age, "gateways": cached.get("gateways", [])} if token and _token_works(token):
if token_from_storage_state(): age = int(time.time() - cached["captured_at"])
return {"logged_in": True, "source": "cache", "token_age_s": age,
"gateways": cached.get("gateways", [])}
tok = token_from_storage_state()
if tok and _token_works(tok):
return {"logged_in": True, "source": "storage_state"} return {"logged_in": True, "source": "storage_state"}
try: return {
ok = ensure_logged_in() "logged_in": False,
return {"logged_in": bool(ok), "source": "browser"} "error": "Session HelloFresh absente ou expirée (aucun token valide accepté par l'API). "
except Exception as e: "Refaire le login en local (ANTICOCO_HEADLESS=0) puis re-sync .session/.",
return {"logged_in": False, "error": str(e)} }

159
server.py
View File

@@ -4,14 +4,17 @@ Client visé : Hermes (Nous Research), sur le même homelab. Transport streamabl
sur 127.0.0.1:$ANTICOCO_PORT/mcp. Si Hermes n'accepte que le stdio, changer le 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). `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 Le SDK MCP (>=1.x) appelle les outils sync DIRECTEMENT dans la boucle asyncio, ce qui
d'utiliser l'API *synchrone* de Playwright (auth.py) sans conflit de boucle asyncio. casse l'API *synchrone* de Playwright (auth.py). On déclare donc les outils en `async`
et on déporte leur corps bloquant dans un thread worker via `anyio.to_thread.run_sync`,
où aucune boucle asyncio ne tourne. Valable pour stdio comme pour streamable-http.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import anyio
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -24,115 +27,145 @@ mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT)
@mcp.tool() @mcp.tool()
def hf_auth_status() -> dict: async def hf_auth_status() -> dict:
"""État de la connexion HelloFresh (utilise le token en cache s'il est frais).""" """État de la connexion HelloFresh (utilise le token en cache s'il est frais)."""
return auth.auth_status() return await anyio.to_thread.run_sync(auth.auth_status)
@mcp.tool() @mcp.tool()
def hf_login() -> dict: async def hf_login() -> dict:
"""S'assure d'être connecté et capture un bearer token frais. """S'assure d'être connecté et capture un bearer token frais.
En headless (homelab), échoue si la session a expiré → refaire le login local. En headless (homelab), échoue si la session a expiré → refaire le login local.
""" """
ok = auth.ensure_logged_in() def _impl() -> dict:
if not ok: ok = auth.ensure_logged_in()
return {"logged_in": False, "error": "login non établi (timeout ou session expirée)"} if not ok:
info = auth.capture_token(force=True) return {"logged_in": False, "error": "login non établi (timeout ou session expirée)"}
return {"logged_in": True, "gateways": info.get("gateways", [])} info = auth.capture_token(force=True)
return {"logged_in": True, "gateways": info.get("gateways", [])}
return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @mcp.tool()
def hf_list_weeks() -> list[dict]: async def hf_list_weeks() -> list[dict]:
"""Liste les semaines de l'abonnement encore modifiables (handle + dates).""" """Liste les semaines de l'abonnement encore modifiables (handle + dates)."""
with api.HelloFreshClient() as client: def _impl() -> list[dict]:
weeks = client.get_editable_weeks() with api.HelloFreshClient() as client:
return [{"week": d.week, "delivery_date": d.delivery_date, "cutoff_date": d.cutoff_date, weeks = client.get_editable_weeks()
"status": d.status, "editable": d.editable} for d in weeks] return [{"week": d.week, "delivery_date": d.delivery_date, "cutoff_date": d.cutoff_date,
"status": d.status, "editable": d.editable} for d in weeks]
return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @mcp.tool()
def hf_get_menu(week: str = "") -> dict: async def hf_account_info() -> dict:
"""Infos du compte HelloFresh : client, abonnement, adresse, prochaine livraison.
Lecture seule. Aucune donnée de paiement sensible n'est renvoyée (méthode seulement).
"""
def _impl() -> dict:
with api.HelloFreshClient() as client:
return client.account_info()
return await anyio.to_thread.run_sync(_impl)
@mcp.tool()
async def hf_get_menu(week: str = "") -> dict:
"""Toutes les recettes proposées pour une semaine, chacune annotée. """Toutes les recettes proposées pour une semaine, chacune annotée.
`week` vide = semaine courante (format 'YYYY-Www'). Chaque recette porte `week` vide = semaine courante (format 'YYYY-Www'). Chaque recette porte
`contains_excluded` (true si ingrédient banni, coco en tête) et `matched_excludes`. `contains_excluded` (true si ingrédient banni, coco en tête) et `matched_excludes`.
""" """
w = week or api.current_week() def _impl() -> dict:
with api.HelloFreshClient() as client: w = week or api.current_week()
recipes = client.get_menu(w) with api.HelloFreshClient() as client:
hf_filter.annotate(recipes) recipes = client.get_menu(w)
return { hf_filter.annotate(recipes)
"week": w, return {
"count": len(recipes), "week": w,
"recipes": [r.summary() for r in recipes], "count": len(recipes),
} "recipes": [r.summary() for r in recipes],
}
return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @mcp.tool()
def hf_propose(week: str = "", count: int = 0) -> dict: async def hf_propose(week: str = "", count: int = 0) -> dict:
"""Shortlist de recettes SANS ingrédient exclu, classée par préférences. """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. `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. Étape « je propose » : rien n'est écrit ici — utiliser hf_confirm_selection() ensuite.
""" """
w = week or api.current_week() def _impl() -> dict:
with api.HelloFreshClient() as client: w = week or api.current_week()
recipes = client.get_menu(w) with api.HelloFreshClient() as client:
safe = hf_filter.propose(recipes, count=count or None) recipes = client.get_menu(w)
excluded = [r.summary() for r in recipes if r.contains_excluded] safe = hf_filter.propose(recipes, count=count or None)
return { excluded = [r.summary() for r in recipes if r.contains_excluded]
"week": w, return {
"proposed": [r.summary() for r in safe], "week": w,
"excluded_for_coco_etc": excluded, "proposed": [r.summary() for r in safe],
"note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", "excluded_for_coco_etc": excluded,
} "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).",
}
return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @mcp.tool()
def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict: async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict:
"""ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation). """ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation).
Garde-fou : refuse toute recette contenant un ingrédient exclu (coco !). Garde-fou : refuse toute recette contenant un ingrédient exclu (coco !).
`dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification). `dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification).
""" """
with api.HelloFreshClient() as client: def _impl() -> dict:
menu = client.get_menu(week) with api.HelloFreshClient() as client:
hf_filter.annotate(menu) menu = client.get_menu(week)
by_id = {r.id: r for r in menu} hf_filter.annotate(menu)
bad = [rid for rid in recipe_ids if rid in by_id and by_id[rid].contains_excluded] by_id = {r.id: r for r in menu}
if bad: bad = [rid for rid in recipe_ids if rid in by_id and by_id[rid].contains_excluded]
return { if bad:
"ok": False, return {
"error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).", "ok": False,
"offending_ids": bad, "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: unknown = [rid for rid in recipe_ids if rid not in by_id]
return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.", if unknown:
"unknown_ids": unknown} return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.",
result = client.set_selection(week, recipe_ids, dry_run=dry_run) "unknown_ids": unknown}
return {"ok": True, "week": week, "selected": recipe_ids, "dry_run": dry_run, result = client.set_selection(week, recipe_ids, dry_run=dry_run)
"api_response": result} return {"ok": True, "week": week, "selected": recipe_ids, "dry_run": dry_run,
"api_response": result}
return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @mcp.tool()
def hf_get_excludes() -> list[str]: async def hf_get_excludes() -> list[str]:
"""Liste actuelle des ingrédients exclus.""" """Liste actuelle des ingrédients exclus."""
return hf_filter.load_excludes() return await anyio.to_thread.run_sync(hf_filter.load_excludes)
@mcp.tool() @mcp.tool()
def hf_add_exclude(term: str) -> list[str]: async def hf_add_exclude(term: str) -> list[str]:
"""Ajoute un ingrédient à exclure. Renvoie la nouvelle liste.""" """Ajoute un ingrédient à exclure. Renvoie la nouvelle liste."""
return hf_filter.add_exclude(term) return await anyio.to_thread.run_sync(hf_filter.add_exclude, term)
@mcp.tool() @mcp.tool()
def hf_remove_exclude(term: str) -> list[str]: async def hf_remove_exclude(term: str) -> list[str]:
"""Retire un ingrédient de la liste d'exclusion. Renvoie la nouvelle liste.""" """Retire un ingrédient de la liste d'exclusion. Renvoie la nouvelle liste."""
return hf_filter.remove_exclude(term) return await anyio.to_thread.run_sync(hf_filter.remove_exclude, term)
if __name__ == "__main__": if __name__ == "__main__":
mcp.run(transport="streamable-http") # Défaut homelab/Hermes : streamable-http. Pour Claude Code local : ANTICOCO_TRANSPORT=stdio.
transport = os.environ.get("ANTICOCO_TRANSPORT", "streamable-http")
mcp.run(transport=transport)