"""FlowDeck — serveur MCP (Fusion, branchement du service d'agents). 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 et écrit en son nom. Objets exposés : les **workspaces** (conteneurs de pages) et leurs pages, les **collections** (bases de données type Notion — ne pas confondre : une collection vit dans un workspace ou une page), 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. Écritures limitées à la création/mise à jour de pages et à la création de collections ; aucune suppression. """ 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_workspaces", "description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages " "de l'utilisateur (ex. Techno, Famille Charest), avec leur " "nombre de pages actives. À ne pas confondre avec les " "collections (bases de données).", "inputSchema": {"type": "object", "properties": {}}}, {"name": "get_workspace", "description": "Lit un workspace et l'arborescence de ses pages " "(titres, parent, section) — pages classiques, pas les " "lignes des collections.", "inputSchema": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}, {"name": "list_collections", "description": "Liste les COLLECTIONS Flowdeck — les bases de données " "de type Notion (tables, CRM, calendriers, formulaires), " "avec leur nombre de lignes. Ce ne sont PAS les workspaces : " "une collection vit dans un workspace ou dans une page.", "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": {}}}, {"name": "create_page", "description": "Crée une page dans une collection Flowdeck.", "inputSchema": {"type": "object", "properties": {"collection_id": {"type": "integer"}, "title": {"type": "string"}, "icon": {"type": "string"}, "properties": {"type": "object"}}, "required": ["collection_id", "title"]}}, {"name": "update_page", "description": "Met à jour une page Flowdeck (titre et/ou propriétés ; " "les propriétés fournies remplacent property_values_json).", "inputSchema": {"type": "object", "properties": {"id": {"type": "integer"}, "title": {"type": "string"}, "properties": {"type": "object"}}, "required": ["id"]}}, {"name": "create_collection", "description": "Crée une collection Flowdeck (projet / espace de travail).", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "icon": {"type": "string"}}, "required": ["name"]}}, ] 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_workspaces": rows = conn.execute( "SELECT w.id, w.name, w.owner_id, " "(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id " " AND p.deleted_at IS NULL) AS page_count " "FROM workspaces w ORDER BY w.name").fetchall() return [dict(r) for r in rows] if nom == "get_workspace": ws = conn.execute("SELECT * FROM workspaces WHERE id = ?", (int(args.get("id", 0)),)).fetchone() if ws is None: return {"erreur": "workspace introuvable"} pages = conn.execute( "SELECT id, title, parent_id, parent_section, updated_at " "FROM pages WHERE workspace_id = ? AND deleted_at IS NULL " "ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall() return {"workspace": {"id": ws["id"], "name": ws["name"], "owner_id": ws["owner_id"]}, "pages": [dict(p) for p in pages]} 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] if nom == "create_page": try: collection_id = int(args.get("collection_id")) except (TypeError, ValueError): return {"erreur": "collection_id requis (entier)"} title = str(args.get("title") or "").strip() if not title: return {"erreur": "titre vide"} coll = conn.execute("SELECT * FROM collections WHERE id = ?", (collection_id,)).fetchone() if coll is None: return {"erreur": "collection introuvable"} props = args.get("properties") if props is None: props = {} if not isinstance(props, dict): return {"erreur": "properties doit être un objet"} icon = str(args.get("icon") or "file") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages " "WHERE collection_id = ?", (collection_id,)).fetchone()[0] cur = conn.execute( "INSERT INTO collection_pages " "(collection_id, title, icon, position, property_values_json) " "VALUES (?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, json.dumps(props, ensure_ascii=False))) conn.commit() page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (cur.lastrowid,)).fetchone() return _page_resume(page) if nom == "update_page": try: page_id = int(args.get("id")) except (TypeError, ValueError): return {"erreur": "id requis (entier)"} page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (page_id,)).fetchone() if page is None: return {"erreur": "page introuvable"} title = page["title"] if "title" in args and args.get("title") is not None: title = str(args.get("title") or "").strip() if not title: return {"erreur": "titre vide"} props_json = page["property_values_json"] if "properties" in args and args.get("properties") is not None: props = args.get("properties") if not isinstance(props, dict): return {"erreur": "properties doit être un objet"} props_json = json.dumps(props, ensure_ascii=False) conn.execute( "UPDATE collection_pages SET title = ?, property_values_json = ?, " "updated_at = CURRENT_TIMESTAMP WHERE id = ?", (title, props_json, page_id)) conn.commit() updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (page_id,)).fetchone() return _page_resume(updated) if nom == "create_collection": name = str(args.get("name") or "").strip() if not name: return {"erreur": "nom vide"} description = str(args.get("description") or "") icon = str(args.get("icon") or "📋") cur = conn.execute( "INSERT INTO collections (name, description, icon, schema_json) " "VALUES (?, ?, ?, '[]')", (name, description, icon)) new_id = cur.lastrowid # Vue par défaut, comme la création via l'API collections # (app/routers/collections/crud.py). conn.execute( "INSERT INTO collection_views " "(collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", (new_id, "Default View", "table", json.dumps({ "visible_properties": ["Title"], "sorts": [], "filters": []}))) conn.commit() coll = conn.execute("SELECT * FROM collections WHERE id = ?", (new_id,)).fetchone() return {"id": coll["id"], "name": coll["name"], "description": coll["description"], "icon": coll["icon"]} 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": False, "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}"}}