feat: Fusion ACC-2.1 — preset connecteur TikTak (MCP) et outils agent mcp_tiktak_* (#19)
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user