From 6a75b189ec0f7e7d9c59261cf8e279ef75a8c4b2 Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 20 Oct 2026 23:23:26 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Fusion=20ACC-2.1=20=E2=80=94=20preset?= =?UTF-8?q?=20connecteur=20TikTak=20(MCP)=20et=20outils=20agent=20mcp=5Fti?= =?UTF-8?q?ktak=5F*=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 ++++++ VERSION | 2 +- app/main.py | 2 +- app/services/connectors.py | 12 ++- app/services/mcp_client.py | 5 +- app/templates/agent_panel.html | 1 + static/js/agent_panel_2.js | 5 +- tests/test_fusion_tiktak.py | 136 +++++++++++++++++++++++++++++++++ 8 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 tests/test_fusion_tiktak.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d5eaf8b..7d21e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - FlowDeck +## v7.73.0 (2026-10-20) — Fusion ACC-2.1 : connecteur MCP Flowdeck → TikTak + +### Added + +- **Preset connecteur « TikTak »** (Fusion ACC-2.1, issue #19 — première + paire de fédération MCP, décision C3) : nouveau kind `tiktak` + (`app/services/connectors.py`, preset URL `https://tiktak.dracodev.net/mcp`, + auth bearer), proposé dans le formulaire des connecteurs de l'agent + (le nom « TikTak » est pré-rempli). La clé `tck_` se crée dans + TikTak → Réglages → Intégrations ; elle reste chiffrée et n'est + jamais renvoyée par l'API. +- Le kind `tiktak` suit le comportement MCP partout : probe = + handshake + `tools/list` (`connectors.probe`, `connector_fetch`), + outils cachés et exposés à l'agent comme outils dynamiques + `mcp_tiktak_list_today_tasks`, `mcp_tiktak_search_tasks`, + `mcp_tiktak_get_task` (`app/services/mcp_client.py`). +- **Test de transport T0 réel** (hors CI) : vrai client Flowdeck + contre un vrai serveur TikTak local — 7/7 (création, handshake, + 3 outils, les trois lectures, refus 401 sans clé). Harnais et + résultats : dossier du programme Fusion (`t0-flowdeck-tiktak/`). + Tests CI : `tests/test_fusion_tiktak.py` (dialecte JSON-RPC de + TikTak rejoué). + ## v7.72.0 (2026-10-20) — Fusion bloc 1 : barre d'apps commune + références croisées ### Added diff --git a/VERSION b/VERSION index f4b5250..55ba97f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.72.0 +7.73.0 diff --git a/app/main.py b/app/main.py index dd2df3d..6332b3a 100644 --- a/app/main.py +++ b/app/main.py @@ -192,7 +192,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.72.0", + version="7.73.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/services/connectors.py b/app/services/connectors.py index 30ce4f4..cb0027d 100644 --- a/app/services/connectors.py +++ b/app/services/connectors.py @@ -24,7 +24,7 @@ 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") +CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak") 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). @@ -35,6 +35,12 @@ PRESETS = { "hint": "BotFather → Bot Token (jeton dans l'URL)"}, "mcp": {"url": "https://", "auth": "bearer", "hint": "URL du endpoint MCP (streamable HTTP)"}, + # Fusion ACC-2.1 — première paire de fédération : le serveur MCP en + # lecture de TikTak (POST /mcp, JSON-RPC) ; la clé `tck_` se crée + # dans TikTak → Réglages → Intégrations. Comportement MCP standard + # (probe, cache d'outils, outils dynamiques mcp_tiktak_*). + "tiktak": {"url": "https://tiktak.dracodev.net/mcp", "auth": "bearer", + "hint": "TikTak → Réglages → Intégrations → clé API tck_…"}, } OAUTH_KINDS = oauth_connectors.OAUTH_KINDS FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe) @@ -253,7 +259,7 @@ 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 == "mcp": + if kind in ("mcp", "tiktak"): from app.services import mcp_client tools = await mcp_client.initialize_and_list(int(ref)) status, detail = "ok", f"MCP · {len(tools)} outil(s)" @@ -303,7 +309,7 @@ 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": + if kind in ("mcp", "tiktak"): from app.services import mcp_client return {"status": "ok", "text": mcp_client.tools_text(int(ref))} if kind == "gitea": diff --git a/app/services/mcp_client.py b/app/services/mcp_client.py index 5b28794..f7f24fa 100644 --- a/app/services/mcp_client.py +++ b/app/services/mcp_client.py @@ -81,7 +81,7 @@ def _server(connector_id: int) -> dict: row = get(connector_id) if row is None: raise ValueError("Serveur MCP introuvable") - if row["kind"] != "mcp": + if row["kind"] not in ("mcp", "tiktak"): raise ValueError("Ce connecteur n'est pas un serveur MCP") if not row["enabled"]: raise ValueError("Serveur MCP désactivé") @@ -136,7 +136,8 @@ 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" + "SELECT id, tools_json FROM agent_connectors " + "WHERE kind IN ('mcp', 'tiktak') AND enabled=1" ).fetchall() out: list[dict] = [] for r in rows: diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 05ded56..f58e5da 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -451,6 +451,7 @@ body.fd-ap-resizing *{cursor:col-resize!important} + diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index a67b78c..dfd7727 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -1423,10 +1423,13 @@ hint:'Discord Developers → Bot Token (jeton en Authorization)'}, telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none', hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'}, - mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'} + mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}, + tiktak: {url:'https://tiktak.dracodev.net/mcp', auth:'bearer', name:'TikTak', + hint:'TikTak → Réglages → Intégrations → clé API tck_…'} }; var p = presets[f.kind] || presets.custom; f.url = p.url; f.auth = p.auth; f.hint = p.hint; + if (p.name && !(f.name || '').trim()) f.name = p.name; }, connectorCreate(){ var self = this, f = this.connectorForm; diff --git a/tests/test_fusion_tiktak.py b/tests/test_fusion_tiktak.py new file mode 100644 index 0000000..442fc74 --- /dev/null +++ b/tests/test_fusion_tiktak.py @@ -0,0 +1,136 @@ +"""Fusion ACC-2.1 — connecteur MCP Flowdeck → TikTak (première paire). + +Le preset « tiktak » crée un connecteur MCP préconfiguré (URL du +endpoint /mcp de TikTak, auth bearer = clé `tck_` chiffrée). Le probe +parle le dialecte JSON-RPC réel de TikTak (initialize → +protocolVersion 2025-03-26, tools/list avec ses trois outils en +lecture, tools/call → content[0].text en JSON) ; les outils sont +cachés puis exposés à l'agent comme `mcp_tiktak_*`. +""" + +import asyncio +import json + +import pytest + +from app.db import get_conn +from app.services import connectors, mcp_client +from app.services.importers import url_fetch +from app.services.tool_registry import ToolRegistry + + +@pytest.fixture(autouse=True) +def _dns_monde_reel(monkeypatch): + """L'environnement de test local résout les hôtes publics vers + 198.18.0.0/15 (plage « reserved ») : la garde SSRF les refuse, alors + qu'en production/CI tiktak.dracodev.net résout vers Cloudflare. + On neutralise la résolution pour la création — le comportement du + produit n'est pas modifié, seuls les tests s'affranchissent du DNS + local (les tests v755/v757 échouent localement pour la même cause). + """ + monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip()) + +# Outils tels que TikTak les déclare (app/routers/sync.py, _OUTILS). +OUTILS_TIKTAK = [ + {"name": "list_today_tasks", + "description": "Liste les tâches actives de l'utilisateur échéant aujourd'hui.", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "search_tasks", + "description": "Recherche des tâches et des notes par mots.", + "inputSchema": {"type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"]}}, + {"name": "get_task", + "description": "Lit une tâche par son identifiant.", + "inputSchema": {"type": "object", + "properties": {"id": {"type": "integer"}}, + "required": ["id"]}}, +] + + +def _creer_tiktak(client, **kw): + body = {"name": "TikTak", "kind": "tiktak", + "url": "https://tiktak.dracodev.net/mcp", + "secret": "tck_cle_de_test", "auth": "bearer", **kw} + r = client.post("/api/agent/connectors", json=body) + assert r.status_code == 200, r.text + return r.json() + + +def _faux_rpc_tiktak(appels, entetes): + """Rejoue le serveur MCP de TikTak (POST /mcp, JSON-RPC).""" + async def fake_rpc(url, payload, headers=None): + assert url == "https://tiktak.dracodev.net/mcp" + entetes.append(headers or {}) + appels.append(payload.get("method")) + method = payload.get("method") + if method == "initialize": + return {"result": {"protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "tiktak", "version": "0.51.0"}}} + if method == "tools/list": + return {"result": {"tools": OUTILS_TIKTAK}} + if method == "tools/call": + params = payload.get("params") or {} + if params.get("name") == "list_today_tasks": + res = [{"id": 42, "title": "Appeler le dentiste", "list": "Perso"}] + else: + res = {"id": 42, "title": "Appeler le dentiste"} + return {"result": {"content": [{"type": "text", + "text": json.dumps(res, ensure_ascii=False)}]}} + return {} + return fake_rpc + + +def test_preset_tiktak_et_secret_chiffre(client): + row = _creer_tiktak(client) + assert row["kind"] == "tiktak" and row["auth"] == "bearer" + assert row["has_secret"] is True + assert "tck_cle_de_test" not in json.dumps(row) # jamais renvoyée + assert connectors.PRESETS["tiktak"]["url"] == "https://tiktak.dracodev.net/mcp" + hdrs = connectors._headers(row["id"]) + assert hdrs["Authorization"] == "Bearer tck_cle_de_test" # ce qu'attend TikTak + + +def test_probe_tiktak_handshake_et_cache(client, monkeypatch): + row = _creer_tiktak(client) + appels, entetes = [], [] + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak(appels, entetes)) + + res = asyncio.run(connectors.probe(str(row["id"]))) + assert res["status"] == "ok", res + assert "3 outil(s)" in res["detail"] + assert appels == ["initialize", "notifications/initialized", "tools/list"] + assert entetes[0]["Authorization"] == "Bearer tck_cle_de_test" + + with get_conn() as conn: + tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?", + (row["id"],)).fetchone()["tools_json"] + for nom in ("mcp_tiktak_list_today_tasks", "mcp_tiktak_search_tasks", + "mcp_tiktak_get_task"): + assert nom in tools + + +def test_outils_tiktak_exposes_a_l_agent(client, monkeypatch): + row = _creer_tiktak(client) + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], [])) + asyncio.run(connectors.probe(str(row["id"]))) + + reg = ToolRegistry() + schema = {t["name"]: t for t in reg.schema()} + assert "mcp_tiktak_list_today_tasks" in schema + assert schema["mcp_tiktak_search_tasks"]["parameters"]["required"] == ["query"] + assert schema["mcp_tiktak_get_task"]["parameters"]["required"] == ["id"] + + res = asyncio.run(reg.execute("mcp_tiktak_list_today_tasks", {})) + assert res.status == "success" + assert "Appeler le dentiste" in res.data["text"] + + +def test_tiktak_desactive_masque_ses_outils(client, monkeypatch): + row = _creer_tiktak(client) + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], [])) + asyncio.run(connectors.probe(str(row["id"]))) + client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False}) + noms = {t["name"] for t in ToolRegistry().schema()} + assert "mcp_tiktak_list_today_tasks" not in noms