Compare commits

...
4 Commits
Author SHA1 Message Date
Spark 215990adc0 doc : entrée CHANGELOG 7.76.1
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m38s
FlowDeck CI / docker (push) Skipped
2026-10-22 08:42:52 -04:00
Spark fae335f808 7.76.1 — correctif : /mcp exempté du CSRF (client Bearer machine-à-machine)
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m55s
FlowDeck CI / docker (push) Skipped
Le middleware CSRF rejetait tout POST /mcp en 403 avant la route ;
les tests passaient car leur client porte session + CSRF. Test de
régression : client anonyme Bearer seul (200 avec jeton, 401 sans).
2026-10-22 08:42:42 -04:00
Spark 465b72235c 7.76.0 — panneau Spark : section Missions (J5), assets à jour
FlowDeck CI / test (push) Failing after 40m42s
FlowDeck CI / lint (push) Failing after 1m27s
FlowDeck CI / docker (push) Skipped
- Même panneau partagé que TikTak : missions longues, sélecteur
  d'exécuteur, rapports, annulation
2026-10-22 03:39:40 +00:00
Spark e36f80714c 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
2026-10-22 03:29:09 +00:00
7 changed files with 387 additions and 15 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog - FlowDeck
## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF
### Corrigé
- **Tout client MCP réel recevait 403 sur `initialize`** : le
middleware CSRF exigeait un jeton de session pour tout POST hors
préfixes exemptés, et `/mcp` n'y était pas — alors que c'est du
machine-à-machine authentifié par jeton d'API (Bearer), comme
`/scim/v2`. Les tests ne voyaient rien : leur client porte une
session et un CSRF. `/mcp` rejoint les préfixes exemptés du CSRF
(son authentification propre est inchangée : sans jeton valide,
toujours 401). Test de régression ajouté : client Bearer anonyme,
sans session ni CSRF.
## 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.1
+6 -1
View File
@@ -20,6 +20,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# Même logique pour ``/mcp`` (Fusion, 7.76.1) : le serveur MCP est
# machine-à-machine, authentifié par les jetons d'API personnels
# (Bearer) — le service d'agents ne peut pas envoyer de X-CSRF-Token.
# Sans cette exemption, tout POST /mcp mourait en 403 avant la route
# (les tests passaient : leur client porte session + CSRF).
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
@@ -30,7 +35,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/mcp",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
+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")
+21
View File
@@ -60,3 +60,24 @@
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: #6c7268; }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; }
+116 -3
View File
@@ -1,12 +1,16 @@
/* Panneau Spark — branchement au service d'agents (Fusion, jalons J1-J2).
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps
* GET /api/v1/conversations/… — historique de la conversation
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5) : lancement,
* suivi, rapport, annulation
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré sont affichés tels quels, jamais masqués. */
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
@@ -37,12 +41,13 @@
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Jalon J2 : conversation réelle — Spark peut lire tes tâches et tes cartes via les outils des apps ; toute écriture passe par une approbation.</div>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -52,6 +57,7 @@
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
@@ -177,6 +183,113 @@
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
"<div class='m-meta'>" + esc(m.executor) +
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport;
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function chargerHistorique() {
if (historiqueCharge || !convId) return;
historiqueCharge = true;
+99 -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,96 @@ 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_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
"""Régression 7.76.1 : un vrai client MCP est machine-à-machine
(Bearer seul, ni session ni jeton CSRF) — exactement le service
d'agents. Le client de test habituel porte une session + un CSRF,
ce qui masquait le 403 du middleware en production."""
from tests.conftest import anon
anon(client)
r = _rpc(client, jeton, 60, "initialize", {})
assert r.status_code == 200, r.text
cols = _appel(client, jeton, 61, "list_collections")
assert any(c["name"] == "Projet Alpha" for c in cols)
r = _rpc(client, None, 62, "tools/list")
assert r.status_code == 401, r.text
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"]