Flowdeck 7.76.0 — serveur MCP en écriture (create_page, update_page, create_collection)

- create_page: collection_id (int, requis), title (string, requis), icon (optionnel), properties (objet optionnel -> property_values_json)
- update_page: id (int, requis), title (optionnel), properties (objet optionnel, remplace property_values_json)
- create_collection: name (requis), description (optionnel), icon (optionnel) + vue par défaut
- Aucune suppression; erreurs métier en {"erreur": "..."}; GET /mcp read_only=false et liste mise à jour
- Tests étendus: création+relecture, mise à jour, create_collection, erreurs, 401 sans jeton sur écriture
This commit is contained in:
Spark
2026-10-22 03:29:09 +00:00
parent 9fe5114669
commit e36f80714c
4 changed files with 215 additions and 11 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog - FlowDeck
## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture
### Ajouté
- **Serveur MCP en écriture** (`POST /mcp`) : en plus des lectures
existantes, trois nouveaux outils — `create_page`
(`collection_id`, `title`, `icon` optionnel, `properties` optionnel
→ `property_values_json`), `update_page` (`id`, `title` optionnel,
`properties` optionnel qui remplace `property_values_json`) et
`create_collection` (`name`, `description` optionnel, `icon`
optionnel, avec vue par défaut comme l'API collections native).
Aucune suppression. Les erreurs métier (collection/page
introuvable, titre vide) reviennent en `{"erreur": "..."}` dans le
contenu texte, comme les lectures. `GET /mcp` reflète les nouveaux
outils (`read_only: false`). Authentification inchangée : jetons
d'API personnels existants.
- **Redéploiement Docker à la charge de Bruno** pour mettre cette
version en production.
## v7.75.0 (2026-10-21) — Fusion : serveur MCP + panneau Spark
### Ajouté
+1 -1
View File
@@ -1 +1 @@
7.75.0
7.76.0
+111 -6
View File
@@ -1,20 +1,21 @@
"""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.
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.
il lit et écrit 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.
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
@@ -51,6 +52,29 @@ OUTILS = [
{"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"]}},
]
@@ -117,6 +141,87 @@ def _appel_outil(nom: str, args: dict):
"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}"}
@@ -133,7 +238,7 @@ def _version() -> str:
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]}
"read_only": False, "tools": [t["name"] for t in OUTILS]}
@router.post("/mcp")
+84 -4
View File
@@ -1,8 +1,9 @@
"""Fusion — serveur MCP Flowdeck (branchement du service d'agents).
POST /mcp en lecture seule, authentifié par les jetons d'API
personnels existants (api_tokens). Le service d'agents lit les
collections et leurs pages au nom du propriétaire du jeton.
POST /mcp, authentifié par les jetons d'API personnels existants
(api_tokens). Le service d'agents lit et écrit (création/mise à
jour de pages, création de collections — aucune suppression) au
nom du propriétaire du jeton.
"""
import hashlib
import json
@@ -68,7 +69,8 @@ def test_mcp_handshake_et_outils(client, jeton):
r = _rpc(client, jeton, 2, "tools/list")
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_collections", "get_collection", "get_page",
"search_pages", "list_repos"]
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
def test_mcp_collections_et_pages(client, jeton, donnees):
@@ -84,3 +86,81 @@ def test_mcp_collections_et_pages(client, jeton, donnees):
assert [p["title"] for p in res] == ["Page de cadrage"]
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_create_page_puis_relecture(client, jeton, donnees):
creee = _appel(client, jeton, 10, "create_page", {
"collection_id": donnees, "title": "Page créée via MCP",
"icon": "file", "properties": {"statut": "à faire", "priorite": "haute"}})
assert creee["title"] == "Page créée via MCP"
assert creee["collection_id"] == donnees
assert creee["properties"] == {"statut": "à faire", "priorite": "haute"}
detail = _appel(client, jeton, 11, "get_collection", {"id": donnees})
titres = [p["title"] for p in detail["pages"]]
assert "Page créée via MCP" in titres
relue = [p for p in detail["pages"] if p["id"] == creee["id"]][0]
assert relue["properties"] == {"statut": "à faire", "priorite": "haute"}
def test_mcp_update_page_titre_et_proprietes(client, jeton, donnees):
creee = _appel(client, jeton, 20, "create_page", {
"collection_id": donnees, "title": "Titre initial",
"properties": {"statut": "brouillon"}})
maj = _appel(client, jeton, 21, "update_page", {
"id": creee["id"], "title": "Titre mis à jour",
"properties": {"statut": "publié"}})
assert maj["title"] == "Titre mis à jour"
# properties remplace property_values_json (pas de fusion)
assert maj["properties"] == {"statut": "publié"}
relue = _appel(client, jeton, 22, "get_page", {"id": creee["id"]})
assert relue["title"] == "Titre mis à jour"
assert relue["properties"] == {"statut": "publié"}
def test_mcp_create_collection(client, jeton):
creee = _appel(client, jeton, 30, "create_collection", {
"name": "Projet Bêta", "description": "Créée via MCP", "icon": "🧪"})
assert creee["name"] == "Projet Bêta"
assert creee["description"] == "Créée via MCP"
assert creee["icon"] == "🧪"
cols = _appel(client, jeton, 31, "list_collections")
beta = [c for c in cols if c["name"] == "Projet Bêta"]
assert beta and beta[0]["id"] == creee["id"]
assert beta[0]["page_count"] == 0
detail = _appel(client, jeton, 32, "get_collection", {"id": creee["id"]})
assert detail["collection"]["name"] == "Projet Bêta"
assert detail["pages"] == []
def test_mcp_erreurs_ecritures(client, jeton, donnees):
res = _appel(client, jeton, 40, "create_page", {
"collection_id": 99999, "title": "Orpheline"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 41, "create_page", {
"collection_id": donnees, "title": " "})
assert "erreur" in res
res = _appel(client, jeton, 42, "update_page", {
"id": 99999, "title": "Fantôme"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 43, "create_collection", {"name": " "})
assert "erreur" in res
def test_mcp_ecriture_sans_jeton_401(client, donnees):
r = _rpc(client, None, 50, "tools/call",
{"name": "create_page",
"arguments": {"collection_id": donnees, "title": "Refusée"}})
assert r.status_code == 401
r = _rpc(client, "mauvais-jeton", 51, "tools/call",
{"name": "create_collection", "arguments": {"name": "Refusée"}})
assert r.status_code == 401
def test_mcp_info_get(client, jeton):
r = client.get("/mcp")
assert r.status_code == 200
infos = r.json()
assert infos["read_only"] is False
assert "create_page" in infos["tools"]
assert "update_page" in infos["tools"]
assert "create_collection" in infos["tools"]