7.75.0 — Fusion : serveur MCP lecture seule + panneau Spark
FlowDeck CI / test (push) Failing after 27m36s
FlowDeck CI / lint (push) Failing after 1m27s
FlowDeck CI / docker (push) Skipped

- POST /mcp (JSON-RPC, dialecte TikTak/Kanbante) : list_collections,
  get_collection, get_page, search_pages, list_repos ; auth par les
  jetons d'API personnels existants (api_tokens)
- Panneau Spark vendangé de TikTak, branché sur l'action spark de la
  barre commune ; CSP connect-src : exception nommée agent.dracodev.net
- Tests : test_fusion_mcp_server (3) verts ; audit CSP mis à jour ;
  2 échecs locaux préexistants (DNS) inchangés
This commit is contained in:
Spark
2026-10-21 22:54:42 -04:00
parent a422c7d713
commit 9fe5114669
11 changed files with 634 additions and 5 deletions
+2
View File
@@ -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)
+4 -1
View File
@@ -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'; "
+175
View File
@@ -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}"}}
+5 -1
View File
@@ -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 = "";
+2
View File
@@ -31,6 +31,7 @@
aucune feuille de style (composants blancs en thème sombre). -->
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -2870,6 +2871,7 @@
</script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/draco-panneau.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}