7.75.0 — Fusion : serveur MCP lecture seule + panneau Spark
- 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:
@@ -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)
|
||||
|
||||
@@ -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'; "
|
||||
|
||||
@@ -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}"}}
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;}
|
||||
|
||||
Reference in New Issue
Block a user