- 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
176 lines
7.4 KiB
Python
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}"}}
|