Files
flowdeck/app/routers/mcp_server.py
T
Spark 9fe5114669
FlowDeck CI / test (push) Failing after 27m36s
FlowDeck CI / lint (push) Failing after 1m27s
FlowDeck CI / docker (push) Skipped
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
2026-10-21 22:54:42 -04:00

176 lines
7.4 KiB
Python

"""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}"}}