feat: Fusion ACC-2.1 — preset connecteur TikTak (MCP) et outils agent mcp_tiktak_* (#19)
FlowDeck CI / lint (push) Failing after 2m9s
FlowDeck CI / test (push) Failing after 29m1s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
Spark
2026-10-20 23:23:26 -04:00
parent d8548eede9
commit 6a75b189ec
8 changed files with 178 additions and 8 deletions
+23
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
7.72.0
7.73.0
+1 -1
View File
@@ -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,
+9 -3
View File
@@ -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":
+3 -2
View File
@@ -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:
+1
View File
@@ -451,6 +451,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
<option value="discord">Discord (bot)</option>
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
<option value="tiktak">TikTak — tâches (MCP, Fusion)</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()">
+4 -1
View File
@@ -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;
+136
View File
@@ -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