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