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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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
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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user