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
+209
View File
@@ -0,0 +1,209 @@
"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
def _create(client, **kw):
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Presets Discord / Telegram ──────────────────────────────────────────────
def test_discord_preset_uses_bot_auth(client):
row = _create(client, name="Discord", url="https://discord.com/api/v10",
auth="bot", kind="discord")
assert row["kind"] == "discord" and row["auth"] == "bot"
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
row = _create(client, name="Telegram", kind="telegram", auth="none",
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
with get_conn() as conn:
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["url"]
assert "{secret}" in stored and "123456" not in stored # jamais en clair
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"ok": true}'
monkeypatch.setattr(connectors, "_get", fake_get)
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
assert "Authorization" not in seen[0][1] # auth=none
def test_secret_placeholder_requires_key_and_valid_kinds(client):
no_key = client.post("/api/agent/connectors",
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
bad_kind = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
bad_auth = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
def _mcp_server(client) -> int:
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
def _fake_rpc_factory(calls):
async def fake_rpc(url, payload, headers=None):
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2024-11-05",
"serverInfo": {"name": "demo"}}}
if method == "tools/list":
return {"result": {"tools": [
{"name": "search_docs", "description": "Cherche dans les notes",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
"required": ["q"]}},
{"name": "Echo", "description": "Répète"},
]}}
if method == "tools/call":
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
return {}
return fake_rpc
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
sid = _mcp_server(client)
calls = []
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "ok", res
assert "2 outil(s)" in res["detail"]
assert calls == ["initialize", "notifications/initialized", "tools/list"]
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
rows = client.get("/api/agent/connectors").json()["connectors"]
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
assert me["kind"] == "mcp" and me["tools_count"] == 2
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_demo_server_search_docs" in schema
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
assert "mcp_demo_server_echo" in schema
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
assert res.status == "success"
assert "résultat utile" in res.data["text"]
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
# hors du registre → refus explicite plutôt qu'un appel réseau raté
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
assert res.status == "error" and "inconnu" in res.message.lower()
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
sid = _mcp_server(client)
async def bad_rpc(url, payload, headers=None):
if payload.get("method") == "initialize":
raise ValueError("MCP -32000 : handshake refusé")
return {}
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "error" and "handshake" in res["detail"]
# les outils restent absents : pas de cache partiel
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert tools == "[]"
def test_parse_body_handles_sse_and_rpc_errors():
sse = mcp_client.parse_body(
"text/event-stream; charset=utf-8",
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
assert sse == {"result": {"ok": 1}}
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
with pytest.raises(ValueError, match="illisible"):
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
with pytest.raises(ValueError, match="-32601"):
mcp_client.parse_body("application/json",
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
def test_tool_name_slug():
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
# ── Teams + câblage ─────────────────────────────────────────────────────────
def test_ms365_scopes_include_teams_messages():
from app.services.oauth_connectors import PROVIDERS
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
def test_connectors_menu_wired_for_presets(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
"auth: f.auth || 'bearer'",
"https://discord.com/api/v10",
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
assert token in src, token
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-cn-kind" in html
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# 1 seule section « bientôt » restante (plugins)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 1
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
def test_mcp_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors").status_code == 401
assert client.post("/api/agent/connectors",
json={"name": "x", "url": "https://example.com/x",
"kind": "mcp"}).status_code == 401