359 lines
17 KiB
Python
359 lines
17 KiB
Python
"""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
|
|
from app.services.collection_lifecycle import (
|
|
effective_workspace_sql,
|
|
user_workspace_ids,
|
|
)
|
|
|
|
router = APIRouter(tags=["mcp"])
|
|
|
|
OUTILS = [
|
|
{"name": "list_workspaces",
|
|
"description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
|
|
"(propriétaire ou membre) — ses conteneurs de pages "
|
|
"(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, user_id: int):
|
|
"""Exécute un outil DANS LE PÉRIMÈTRE du propriétaire du jeton.
|
|
|
|
Correctif 7.78.0 (cas réel de Bruno) : la première version listait
|
|
les 52 workspaces de la base — tous les comptes, résidus de tests
|
|
compris. Règle : workspaces dont l'utilisateur est propriétaire
|
|
ou membre ; collections rattachées (directement ou via leur page
|
|
hôte) à l'un de ces workspaces, plus les collections sans aucun
|
|
rattachement (héritage global, comme l'API publique historique).
|
|
Un objet hors périmètre répond « introuvable », sans fuiter son
|
|
existence.
|
|
"""
|
|
with get_conn() as conn:
|
|
accessible = set(user_workspace_ids(conn, user_id))
|
|
eff_sql = effective_workspace_sql("c")
|
|
|
|
def collection_visible(coll_id: int) -> bool:
|
|
row = conn.execute(
|
|
f"SELECT {eff_sql} AS eff FROM collections c WHERE c.id = ?",
|
|
(coll_id,)).fetchone()
|
|
if row is None:
|
|
return False
|
|
return row["eff"] is None or row["eff"] in accessible
|
|
|
|
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 r["id"] in accessible]
|
|
if nom == "get_workspace":
|
|
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
|
|
(int(args.get("id", 0)),)).fetchone()
|
|
if ws is None or ws["id"] not in accessible:
|
|
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(
|
|
f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
|
|
f"WHERE p.collection_id = c.id) AS page_count, "
|
|
f"{eff_sql} AS eff_ws "
|
|
"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 r["eff_ws"] is None or r["eff_ws"] in accessible]
|
|
if nom == "get_collection":
|
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
|
(int(args.get("id", 0)),)).fetchone()
|
|
if coll is None or not collection_visible(coll["id"]):
|
|
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 or not collection_visible(p["collection_id"]):
|
|
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 collection_visible(p["collection_id"])]
|
|
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 or not collection_visible(collection_id):
|
|
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()
|
|
from app.services.agent_evenements import emettre, url_collection
|
|
emettre("flowdeck.page.created", f"page:{page['id']}", title,
|
|
url=url_collection(collection_id), actor="agent:mcp")
|
|
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 or not collection_visible(page["collection_id"]):
|
|
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()
|
|
from app.services.agent_evenements import emettre, url_collection
|
|
emettre("flowdeck.page.updated", f"page:{page_id}", title,
|
|
url=url_collection(page["collection_id"]), actor="agent:mcp")
|
|
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()
|
|
from app.services.agent_evenements import emettre, url_collection
|
|
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
|
|
url=url_collection(new_id), actor="agent:mcp")
|
|
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 ""
|
|
proprietaire = _token_owner(token) if token else None
|
|
if proprietaire 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 {},
|
|
proprietaire["user_id"])
|
|
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}"}}
|