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