feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
- 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:
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.56.0",
|
||||
version="7.57.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -1586,6 +1586,19 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
|
||||
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
|
||||
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
|
||||
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
|
||||
cols = columns(conn, "agent_connectors")
|
||||
if "kind" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
|
||||
if "auth" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
|
||||
if "tools_json" not in cols:
|
||||
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
|
||||
|
||||
|
||||
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
|
||||
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
|
||||
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
|
||||
|
||||
@@ -653,7 +653,9 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
_current_user_id(request)
|
||||
try:
|
||||
return connectors.create_connector(
|
||||
body.get("name") or "", body.get("url") or "", str(body.get("secret") or "")
|
||||
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||
kind=str(body.get("kind") or "custom"),
|
||||
auth=str(body.get("auth") or "bearer"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
+94
-34
@@ -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)))
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
|
||||
|
||||
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
|
||||
→ ``tools/call``. Les outils sont **cachés** dans
|
||||
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
|
||||
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
|
||||
sans état en mémoire.
|
||||
|
||||
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
|
||||
réponse JSON directe, soit la première ligne ``data:`` d'une
|
||||
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
|
||||
répondent en JSON chez la grande majorité des serveurs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.connectors import _headers, _with_secret, get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
|
||||
|
||||
|
||||
def tool_name(server_name: str, tool_name: str) -> str:
|
||||
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
|
||||
def slug(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
|
||||
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
|
||||
return re.sub(r"_{2,}", "_", joined)
|
||||
|
||||
|
||||
def parse_body(content_type: str, body: str, status: int) -> dict:
|
||||
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
|
||||
text = body or ""
|
||||
if "text/event-stream" in (content_type or ""):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
text = line[5:].strip()
|
||||
break
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
err = data["error"] if isinstance(data["error"], dict) else {}
|
||||
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
|
||||
safe = _validate_url(url)
|
||||
hdrs = {"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**(headers or {})}
|
||||
async with shared_client(timeout=15, headers=hdrs) as client:
|
||||
resp = await client.post(safe, json=payload)
|
||||
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
|
||||
resp.status_code)
|
||||
|
||||
|
||||
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
|
||||
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
|
||||
try:
|
||||
await _rpc(url, payload, headers)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("MCP notification ignorée: %s", exc)
|
||||
|
||||
|
||||
def _server(connector_id: int) -> dict:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
raise ValueError("Serveur MCP introuvable")
|
||||
if row["kind"] != "mcp":
|
||||
raise ValueError("Ce connecteur n'est pas un serveur MCP")
|
||||
if not row["enabled"]:
|
||||
raise ValueError("Serveur MCP désactivé")
|
||||
return {"id": row["id"], "name": row["name"], "url": row["url"],
|
||||
"headers": _headers(connector_id)}
|
||||
|
||||
|
||||
def _normalize(server: dict, raw: list) -> list[dict]:
|
||||
out = []
|
||||
for t in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(t, dict) or not t.get("name"):
|
||||
continue
|
||||
params = t.get("inputSchema")
|
||||
if not isinstance(params, dict):
|
||||
params = {"type": "object", "properties": {}}
|
||||
out.append({
|
||||
"name": tool_name(server["name"], t["name"]),
|
||||
"original": str(t["name"]),
|
||||
"description": str(t.get("description") or "Outil MCP"),
|
||||
"parameters": params,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _save_tools(connector_id: int, tools: list[dict]) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
|
||||
(json.dumps(tools), connector_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def initialize_and_list(connector_id: int) -> list[dict]:
|
||||
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
||||
"clientInfo": CLIENT_INFO},
|
||||
}, srv["headers"])
|
||||
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
srv["headers"])
|
||||
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": {}}, srv["headers"])
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
tools = _normalize(srv, result.get("tools") or [])
|
||||
_save_tools(connector_id, tools)
|
||||
return tools
|
||||
|
||||
|
||||
def cached_tools() -> list[dict]:
|
||||
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
tools = json.loads(r["tools_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
tools = []
|
||||
for t in tools if isinstance(tools, list) else []:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
out.append({**t, "connector_id": r["id"]})
|
||||
return out
|
||||
|
||||
|
||||
def tools_text(connector_id: int) -> str:
|
||||
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
|
||||
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
|
||||
if not tools:
|
||||
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
|
||||
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
|
||||
|
||||
|
||||
def _content_text(result: dict) -> str:
|
||||
parts = []
|
||||
for item in result.get("content") or []:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
parts.append(str(item.get("text") or ""))
|
||||
else:
|
||||
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
|
||||
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
|
||||
|
||||
|
||||
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
|
||||
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
data = await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": tool, "arguments": arguments or {}},
|
||||
}, srv["headers"])
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
text = _content_text(result)[:20000]
|
||||
return {"status": "error" if result.get("isError") else "ok", "text": text}
|
||||
@@ -50,6 +50,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"scopes": [
|
||||
"openid", "email", "profile", "offline_access",
|
||||
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
|
||||
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
|
||||
],
|
||||
"extra": {"response_mode": "query", "prompt": "consent"},
|
||||
"probe_path": "/me",
|
||||
|
||||
@@ -1115,6 +1115,36 @@ class ConnectorFetch(Tool):
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
class McpTool(Tool):
|
||||
"""Outil MCP dynamique — instance construite à la volée depuis le cache
|
||||
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
|
||||
|
||||
def __init__(self, entry: dict):
|
||||
self.name = entry["name"]
|
||||
self.description = entry.get("description") or "Outil MCP"
|
||||
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
|
||||
self.entry = entry
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services import mcp_client
|
||||
|
||||
try:
|
||||
res = await mcp_client.call_tool(self.entry["connector_id"],
|
||||
self.entry.get("original") or "", args)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||
logger.warning("MCP tool failed (%s): %s", self.name, exc)
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"MCP indisponible: {exc}")
|
||||
if res.get("status") != "ok":
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=(res.get("text") or "erreur MCP")[:500])
|
||||
text = res.get("text") or ""
|
||||
return ToolResult(status="success", tool=self.name, target_type="mcp",
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
@@ -1133,24 +1163,41 @@ class ToolRegistry:
|
||||
def __init__(self):
|
||||
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
||||
|
||||
def _all(self) -> dict[str, Tool]:
|
||||
"""Outils statiques + **outils MCP dynamiques** (cache en base).
|
||||
|
||||
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
|
||||
donc « Tester le connecteur » suffit à rendre un outil disponible.
|
||||
"""
|
||||
tools = dict(self.tools)
|
||||
try:
|
||||
from app.services import mcp_client
|
||||
for entry in mcp_client.cached_tools():
|
||||
tools[entry["name"]] = McpTool(entry)
|
||||
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
||||
logger.exception("MCP dynamic tools load failed")
|
||||
return tools
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
tools = self._all()
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
return list(tools.keys())
|
||||
return [n for n in tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
tools = self._all()
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.tools[n].parameters}
|
||||
{"name": tools[n].name,
|
||||
"description": tools[n].description,
|
||||
"parameters": tools[n].parameters}
|
||||
for n in self.list(scope)
|
||||
]
|
||||
|
||||
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
||||
impl = self.tools.get(tool)
|
||||
impl = self._all().get(tool)
|
||||
if not impl:
|
||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||
return await impl.execute(args, user_id=user_id)
|
||||
|
||||
@@ -445,11 +445,18 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
|
||||
<label for="fd-plus-cn-kind">Type</label>
|
||||
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
|
||||
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
|
||||
<option value="discord">Discord (bot)</option>
|
||||
<option value="telegram">Telegram (bot)</option>
|
||||
<option value="mcp">Serveur MCP (streamable HTTP)</option>
|
||||
</select>
|
||||
<label for="fd-plus-cn-name">Nom</label>
|
||||
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
|
||||
<label for="fd-plus-cn-url">URL publique (hôtes privés refusés)</label>
|
||||
<input id="fd-plus-cn-url" type="url" x-model="connectorForm.url" placeholder="https://api.exemple.com">
|
||||
<label for="fd-plus-cn-secret">Clé API — stockée chiffrée, jamais renvoyée</label>
|
||||
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
|
||||
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
|
||||
<label for="fd-plus-cn-secret">Clé / jeton — chiffrée, jamais renvoyée</label>
|
||||
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
|
||||
<div class="fd-proposal-bar">
|
||||
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
|
||||
|
||||
Reference in New Issue
Block a user