feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 17m51s
FlowDeck CI / docker (push) Skipped

- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
  agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
  l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
  {secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
  → tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
  text/event-stream), garde SSRF, outils cachés en base (tools_json) —
  le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
  sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
  serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
  badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
  suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
  ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
This commit is contained in:
2026-10-07 09:09:19 -04:00
parent 1d7750bda0
commit f8acb906e5
15 changed files with 668 additions and 60 deletions
+94 -34
View File
@@ -1,7 +1,10 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config,
plus des connecteurs **personnalisation** persistés (URL + clé) en base.
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
d'outils dans ``tools_json``).
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
@@ -10,6 +13,7 @@ provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-
from __future__ import annotations
import json
import logging
from app.config import settings
@@ -20,6 +24,18 @@ from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
PRESETS = {
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
"hint": "Bot Discord Developers → Token"},
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
"mcp": {"url": "https://", "auth": "bearer",
"hint": "URL du endpoint MCP (streamable HTTP)"},
}
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
@@ -48,6 +64,21 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError):
tools = []
return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
"tools_count": len(tools),
}
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
@@ -62,46 +93,45 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors ORDER BY id"
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall()
for r in rows:
out.append({
"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]),
})
return out
return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors WHERE id=?", (connector_id,)
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if not r:
return None
return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])}
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "") -> dict:
def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
from app.services.importers.url_fetch import _validate_url
name = (name or "").strip()
if not name:
raise ValueError("name est requis")
url = _validate_url((url or "").strip()) # lève ValueError si hôte interne
if kind not in CUSTOM_KINDS:
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip():
raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, detail) "
"VALUES (?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", ""),
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
)
conn.commit()
cid = cur.lastrowid
@@ -143,22 +173,44 @@ def delete_connector(connector_id: int) -> bool:
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée."""
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret = ""
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if secret:
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none":
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
def _with_secret(url: str, connector_id: int) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url:
return url
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret)
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
"""GET gardé SSRF (validation + re-vérification après redirection).
@@ -184,7 +236,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return ("custom", row["id"])
return (row["kind"], row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
@@ -193,7 +245,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return ("custom", row["id"])
return (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
@@ -201,7 +253,11 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind in OAUTH_KINDS:
if kind == "mcp":
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
@@ -216,12 +272,13 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
code, _ = await _get(row["url"], _headers(int(ref)))
url = _with_secret(row["url"], int(ref))
code, _ = await _get(url, _headers(int(ref)))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc)
status, detail = "error", str(exc)[:200]
if kind == "custom":
if kind not in NATIVE_KINDS:
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
@@ -246,6 +303,9 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "mcp":
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
@@ -267,7 +327,7 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = row["url"].rstrip("/")
url = _with_secret(row["url"], int(ref)).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))