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
|
# 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
|
## v7.72.0 (2026-10-20) — Fusion bloc 1 : barre d'apps commune + références croisées
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.72.0",
|
version="7.73.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
|
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")
|
AUTH_SCHEMES = ("bearer", "bot", "none")
|
||||||
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
||||||
# par l'utilisateur — jamais codé en dur ici).
|
# par l'utilisateur — jamais codé en dur ici).
|
||||||
@@ -35,6 +35,12 @@ PRESETS = {
|
|||||||
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
||||||
"mcp": {"url": "https://", "auth": "bearer",
|
"mcp": {"url": "https://", "auth": "bearer",
|
||||||
"hint": "URL du endpoint MCP (streamable HTTP)"},
|
"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
|
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
|
||||||
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
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)."""
|
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
||||||
kind, ref = _resolve(connector)
|
kind, ref = _resolve(connector)
|
||||||
try:
|
try:
|
||||||
if kind == "mcp":
|
if kind in ("mcp", "tiktak"):
|
||||||
from app.services import mcp_client
|
from app.services import mcp_client
|
||||||
tools = await mcp_client.initialize_and_list(int(ref))
|
tools = await mcp_client.initialize_and_list(int(ref))
|
||||||
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
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()
|
path = (path or "").strip()
|
||||||
if kind in OAUTH_KINDS:
|
if kind in OAUTH_KINDS:
|
||||||
return await oauth_connectors.api_get(kind, user_id, path)
|
return await oauth_connectors.api_get(kind, user_id, path)
|
||||||
if kind == "mcp":
|
if kind in ("mcp", "tiktak"):
|
||||||
from app.services import mcp_client
|
from app.services import mcp_client
|
||||||
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
||||||
if kind == "gitea":
|
if kind == "gitea":
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ def _server(connector_id: int) -> dict:
|
|||||||
row = get(connector_id)
|
row = get(connector_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError("Serveur MCP introuvable")
|
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")
|
raise ValueError("Ce connecteur n'est pas un serveur MCP")
|
||||||
if not row["enabled"]:
|
if not row["enabled"]:
|
||||||
raise ValueError("Serveur MCP désactivé")
|
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."""
|
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
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()
|
).fetchall()
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
|||||||
<option value="discord">Discord (bot)</option>
|
<option value="discord">Discord (bot)</option>
|
||||||
<option value="telegram">Telegram (bot)</option>
|
<option value="telegram">Telegram (bot)</option>
|
||||||
<option value="mcp">Serveur MCP (streamable HTTP)</option>
|
<option value="mcp">Serveur MCP (streamable HTTP)</option>
|
||||||
|
<option value="tiktak">TikTak — tâches (MCP, Fusion)</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="fd-plus-cn-name">Nom</label>
|
<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()">
|
<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)'},
|
hint:'Discord Developers → Bot Token (jeton en Authorization)'},
|
||||||
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
|
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
|
||||||
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
|
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;
|
var p = presets[f.kind] || presets.custom;
|
||||||
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
|
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
|
||||||
|
if (p.name && !(f.name || '').trim()) f.name = p.name;
|
||||||
},
|
},
|
||||||
connectorCreate(){
|
connectorCreate(){
|
||||||
var self = this, f = this.connectorForm;
|
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