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