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:
159
server.py
159
server.py
@@ -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
|
||||
`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.
|
||||
Le SDK MCP (>=1.x) appelle les outils sync DIRECTEMENT dans la boucle asyncio, ce qui
|
||||
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
|
||||
|
||||
import os
|
||||
|
||||
import anyio
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
@@ -24,115 +27,145 @@ mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT)
|
||||
|
||||
|
||||
@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)."""
|
||||
return auth.auth_status()
|
||||
return await anyio.to_thread.run_sync(auth.auth_status)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def hf_login() -> dict:
|
||||
async 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", [])}
|
||||
def _impl() -> dict:
|
||||
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", [])}
|
||||
|
||||
return await anyio.to_thread.run_sync(_impl)
|
||||
|
||||
|
||||
@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)."""
|
||||
with api.HelloFreshClient() as client:
|
||||
weeks = client.get_editable_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]
|
||||
def _impl() -> list[dict]:
|
||||
with api.HelloFreshClient() as client:
|
||||
weeks = client.get_editable_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()
|
||||
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.
|
||||
|
||||
`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],
|
||||
}
|
||||
def _impl() -> dict:
|
||||
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],
|
||||
}
|
||||
|
||||
return await anyio.to_thread.run_sync(_impl)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
`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).",
|
||||
}
|
||||
def _impl() -> dict:
|
||||
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).",
|
||||
}
|
||||
|
||||
return await anyio.to_thread.run_sync(_impl)
|
||||
|
||||
|
||||
@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).
|
||||
|
||||
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).
|
||||
"""
|
||||
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, dry_run=dry_run)
|
||||
return {"ok": True, "week": week, "selected": recipe_ids, "dry_run": dry_run,
|
||||
"api_response": result}
|
||||
def _impl() -> dict:
|
||||
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, dry_run=dry_run)
|
||||
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()
|
||||
def hf_get_excludes() -> list[str]:
|
||||
async def hf_get_excludes() -> list[str]:
|
||||
"""Liste actuelle des ingrédients exclus."""
|
||||
return hf_filter.load_excludes()
|
||||
return await anyio.to_thread.run_sync(hf_filter.load_excludes)
|
||||
|
||||
|
||||
@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."""
|
||||
return hf_filter.add_exclude(term)
|
||||
return await anyio.to_thread.run_sync(hf_filter.add_exclude, term)
|
||||
|
||||
|
||||
@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."""
|
||||
return hf_filter.remove_exclude(term)
|
||||
return await anyio.to_thread.run_sync(hf_filter.remove_exclude, term)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user