diff --git a/CHANGELOG.md b/CHANGELOG.md
index 239440a..2e597cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog - FlowDeck
+## v7.75.0 (2026-10-21) — Fusion : serveur MCP + panneau Spark
+
+### Ajouté
+
+- **Serveur MCP en lecture seule** (`POST /mcp`, JSON-RPC, même
+ dialecte que TikTak et Kanbante) : `list_collections`,
+ `get_collection`, `get_page`, `search_pages`, `list_repos`.
+ Authentification par les **jetons d'API personnels existants**
+ (Paramètres → Jetons d'API) — le service d'agents lit au nom du
+ propriétaire du jeton qu'on lui crée. Flowdeck était la seule des
+ quatre applications sans porte MCP ; c'est corrigé.
+- **Panneau Spark intégré** : le bouton Spark de la barre commune
+ ouvre le panneau de l'agent commun (conversation, Aujourd'hui,
+ approbations), assets vendangés à l'identique de TikTak.
+
+### Sécurité
+
+- **CSP `connect-src`** : exception nommée et unique pour
+ `https://agent.dracodev.net` (le service d'agents, premier parti) —
+ le panneau en a besoin pour parler au service. Aucun autre hôte
+ externe n'est ouvert ; le `https:` générique reste interdit
+ (test d'audit mis à jour en conséquence).
+
## v7.74.3 (2026-10-21) — Barre commune : le menu du ✦ arrive enfin + palier 900 px
### Corrigé
diff --git a/VERSION b/VERSION
index 8bd97d9..547bef6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.74.3
+7.75.0
diff --git a/app/main.py b/app/main.py
index 72a1998..5b8b1dd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -239,6 +239,8 @@ app.include_router(admin.router)
app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router)
+from app.routers import mcp_server as _mcp_server # Fusion — serveur MCP (service d'agents)
+app.include_router(_mcp_server.router)
app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
diff --git a/app/middleware/security.py b/app/middleware/security.py
index 47b3098..6e67bcc 100644
--- a/app/middleware/security.py
+++ b/app/middleware/security.py
@@ -130,7 +130,10 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
# côté front — grep négatif) et websockets scopés à l'hôte de la
# requête ({host}) → plus de canal d'exfil vers un tiers.
- "connect-src 'self' ws://{host} wss://{host}; "
+ # Exception Fusion (2026-10-21) : le panneau Spark parle au
+ # service d'agents commun (premier parti, même programme) —
+ # seul hôte externe nommé, rien d'autre ne s'ouvre.
+ "connect-src 'self' https://agent.dracodev.net ws://{host} wss://{host}; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
diff --git a/app/routers/mcp_server.py b/app/routers/mcp_server.py
new file mode 100644
index 0000000..e1a5389
--- /dev/null
+++ b/app/routers/mcp_server.py
@@ -0,0 +1,175 @@
+"""FlowDeck — serveur MCP (Fusion, branchement du service d'agents).
+
+Lecture seule, même dialecte JSON-RPC que les autres apps du
+programme (TikTak, Kanbante) : POST /mcp, initialize → tools/list →
+tools/call, réponses en contenu texte JSON.
+
+Authentification : les jetons d'API personnels existants
+(Settings → API tokens, table ``api_tokens``, vérifiés par
+``public_api._token_owner``) — aucune nouvelle machinerie de clés.
+Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
+il lit en son nom.
+
+Objet exposé : les **collections** (les projets/espaces de travail
+de Flowdeck) et leurs pages, la recherche par titre, et le registre
+des dépôts (table ``projects``). Doctrine du programme : Flowdeck
+référence les tâches, il ne les copie pas — aucune tâche TikTak ne
+transite par ici, et ce serveur n'écrit rien.
+"""
+from __future__ import annotations
+
+import json
+
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse, Response
+
+from app.db import get_conn
+from app.routers.public_api import _token_owner
+
+router = APIRouter(tags=["mcp"])
+
+OUTILS = [
+ {"name": "list_collections",
+ "description": "Liste les collections Flowdeck (projets / espaces de travail) "
+ "avec leur nombre de pages.",
+ "inputSchema": {"type": "object", "properties": {}}},
+ {"name": "get_collection",
+ "description": "Lit une collection et ses pages (titres et propriétés).",
+ "inputSchema": {"type": "object",
+ "properties": {"id": {"type": "integer"}},
+ "required": ["id"]}},
+ {"name": "get_page",
+ "description": "Lit une page de collection par son identifiant (titre, propriétés, contenu).",
+ "inputSchema": {"type": "object",
+ "properties": {"id": {"type": "integer"}},
+ "required": ["id"]}},
+ {"name": "search_pages",
+ "description": "Recherche des pages par mots dans leur titre, toutes collections confondues.",
+ "inputSchema": {"type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"]}},
+ {"name": "list_repos",
+ "description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
+ "inputSchema": {"type": "object", "properties": {}}},
+]
+
+
+def _props(brut):
+ """property_values_json → dict quand c'est du JSON, sinon inchangé."""
+ if not brut:
+ return {}
+ try:
+ return json.loads(brut)
+ except (ValueError, TypeError):
+ return brut
+
+
+def _page_resume(row) -> dict:
+ return {"id": row["id"], "collection_id": row["collection_id"],
+ "title": row["title"], "icon": row["icon"],
+ "properties": _props(row["property_values_json"]),
+ "updated_at": row["updated_at"]}
+
+
+def _appel_outil(nom: str, args: dict):
+ with get_conn() as conn:
+ if nom == "list_collections":
+ rows = conn.execute(
+ "SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
+ "WHERE p.collection_id = c.id) AS page_count "
+ "FROM collections c ORDER BY c.name").fetchall()
+ return [{"id": r["id"], "name": r["name"],
+ "description": r["description"], "icon": r["icon"],
+ "is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
+ "page_count": r["page_count"]} for r in rows]
+ if nom == "get_collection":
+ coll = conn.execute("SELECT * FROM collections WHERE id = ?",
+ (int(args.get("id", 0)),)).fetchone()
+ if coll is None:
+ return {"erreur": "collection introuvable"}
+ pages = conn.execute(
+ "SELECT * FROM collection_pages WHERE collection_id = ? "
+ "ORDER BY position, id", (coll["id"],)).fetchall()
+ return {"collection": {"id": coll["id"], "name": coll["name"],
+ "description": coll["description"],
+ "icon": coll["icon"]},
+ "pages": [_page_resume(p) for p in pages]}
+ if nom == "get_page":
+ p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
+ (int(args.get("id", 0)),)).fetchone()
+ if p is None:
+ return {"erreur": "page introuvable"}
+ res = _page_resume(p)
+ for cle in ("content", "blocks", "blocks_json"):
+ if cle in p.keys() and p[cle]:
+ res[cle] = p[cle]
+ return res
+ if nom == "search_pages":
+ q = f"%{str(args.get('query', '')).strip()}%"
+ if q == "%%":
+ return []
+ rows = conn.execute(
+ "SELECT * FROM collection_pages WHERE title LIKE ? "
+ "ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
+ return [_page_resume(p) for p in rows]
+ if nom == "list_repos":
+ rows = conn.execute(
+ "SELECT id, name, proj_type, owner, language, description "
+ "FROM projects ORDER BY proj_type, name").fetchall()
+ return [dict(r) for r in rows]
+ return {"erreur": f"outil inconnu : {nom}"}
+
+
+def _version() -> str:
+ import pathlib
+ try:
+ return (pathlib.Path(__file__).resolve().parent.parent.parent
+ / "VERSION").read_text(encoding="utf-8").strip()
+ except OSError:
+ return "dev"
+
+
+@router.get("/mcp")
+def mcp_info():
+ return {"name": "flowdeck", "version": _version(),
+ "transport": "streamable-http (JSON-RPC 2.0 over POST)",
+ "read_only": True, "tools": [t["name"] for t in OUTILS]}
+
+
+@router.post("/mcp")
+async def mcp(request: Request):
+ auth = request.headers.get("authorization", "")
+ token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
+ if not token or _token_owner(token) is None:
+ return JSONResponse(
+ {"jsonrpc": "2.0", "id": None,
+ "error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
+ status_code=401)
+ try:
+ data = await request.json()
+ except Exception:
+ return JSONResponse(
+ {"jsonrpc": "2.0", "id": None,
+ "error": {"code": -32700, "message": "JSON invalide"}},
+ status_code=400)
+ methode = data.get("method", "")
+ rid = data.get("id")
+ if rid is None: # notification : rien à répondre
+ return Response(status_code=202)
+ if methode == "initialize":
+ return {"jsonrpc": "2.0", "id": rid,
+ "result": {"protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "flowdeck", "version": _version()}}}
+ if methode == "ping":
+ return {"jsonrpc": "2.0", "id": rid, "result": {}}
+ if methode == "tools/list":
+ return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
+ if methode == "tools/call":
+ params = data.get("params") or {}
+ res = _appel_outil(params.get("name", ""), params.get("arguments") or {})
+ return {"jsonrpc": "2.0", "id": rid,
+ "result": {"content": [{"type": "text",
+ "text": json.dumps(res, ensure_ascii=False)}]}}
+ return {"jsonrpc": "2.0", "id": rid,
+ "error": {"code": -32601, "message": f"Méthode inconnue : {methode}"}}
diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html
index 13f7c85..8cadbdf 100644
--- a/app/templates/_draco_bar.html
+++ b/app/templates/_draco_bar.html
@@ -85,7 +85,11 @@
}
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
- if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
+ if (act === "spark") {
+ if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
+ else { dire("Spark : le panneau n'a pas pu se charger."); }
+ return;
+ }
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
diff --git a/app/templates/base.html b/app/templates/base.html
index aa1eb22..54c3dac 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -31,6 +31,7 @@
aucune feuille de style (composants blancs en thème sombre). -->
+