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). --> +