diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e597cd..db31c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/VERSION b/VERSION index 547bef6..d7e7a34 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.75.0 +7.76.0 diff --git a/app/routers/mcp_server.py b/app/routers/mcp_server.py index e1a5389..521ab23 100644 --- a/app/routers/mcp_server.py +++ b/app/routers/mcp_server.py @@ -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") diff --git a/tests/test_fusion_mcp_server.py b/tests/test_fusion_mcp_server.py index 716d7fb..afffd40 100644 --- a/tests/test_fusion_mcp_server.py +++ b/tests/test_fusion_mcp_server.py @@ -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"]