Compare commits

..
3 Commits
Author SHA1 Message Date
Spark 913d6137b1 feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
FlowDeck CI / test (push) Failing after 3h11m27s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped
2026-10-26 16:19:16 -04:00
Spark eff459ddd7 feat(draco): version du service d'agents visible dans le panneau Spark, mode sidebar (7.83.1) — fusion #64
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h58m44s
2026-10-26 16:47:37 +00:00
Spark 19b27698f2 feat(fusion): serveur MCP complété (ACC-5.1) + pont MCP ObsiGate (ACC-2.2) — 7.83.0
FlowDeck CI / test (push) Failing after 3h11m23s
FlowDeck CI / lint (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
ACC-5.1 (#24) — app/routers/mcp_server.py : catalogue §7.2 complété
(list_projects, get_project, read_page tronqué proprement,
list_project_tasks), search_pages en plein texte, liens profonds +
kind sur chaque résultat, scopes du jeton appliqués (écriture
refusée -32002 sans scope write) et journalisation §7.4 de chaque
tools/call dans api_audit_log (clés d'arguments seulement).

ACC-2.2 (#20) — pont MCP Flowdeck → ObsiGate, côté client :
- mcp_client : serveurs à session (capture/réémission de
  Mcp-Session-Id, handshake avant tools/call), décodage SSE
  multi-messages par id de réponse, classification des outils à la
  découverte (readOnlyHint sinon heuristique ; lecture par défaut).
- preset connecteur « obsigate » (URL dérivée dev/prod), kinds MCP
  factorisés (connectors.MCP_KINDS), option dans le panneau agent.
- garde-fou n° 2 : permission_manager.is_write_tool couvre les
  outils MCP écrivants → portes politique d'approbation
  (agent_engine) et rôles (assert_can) ; McpTool bloque tout
  apply_* distant (patron §7.5).

Tests : test_fusion_mcp_server 18, nouveau test_fusion_pont_obsigate
7 (faux serveur ObsiGate à session stricte via le vrai _rpc).
Preuve réelle T0-T5 : attend la clé ObsiGate dédiée (1 vault) et
Q-A/Q-B côté Bruno.
2026-10-26 12:38:04 -04:00
25 changed files with 2000 additions and 53 deletions
+86
View File
@@ -1,5 +1,91 @@
# Changelog - FlowDeck
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
contrat figé ACC-0.3 de `dracodev-commons`). Lecture et validation
d'un `SKILL.md` au format commun (frontmatter YAML : `name`,
`description`-déclencheur, `version`, `apps`, `domaines`, `outils`,
`ecritures` obligatoire dès qu'un outil modifie ; corps en trois
sections ordonnées), conversion vers les champs Flowdeck
(installable via `skill_gallery.upsert_skill`) et conversion
inverse depuis un preset de la galerie. **Preuve du format** :
trois skills de la galerie convertis dans `skills-communs/` —
`compte-rendu-reunion`, `rapport-hebdo`, `resume-document`.
- **ACC-6.2 — pilote d'écriture approuvée** (`app/services/meeting_archive.py`).
Nouvelle route `POST /api/v2/meetings/transcripts/{id}/archiver-obsigate` :
prépare l'archive du résumé de réunion (chemin `Réunions/<date> - <titre>.md`,
contenu Markdown avec lien profond vers la note) et la dépose dans
la **file d'approbations unifiée** (`agent_approvals`, outil
`archiver_reunion_obsigate`) — rien n'est écrit avant approbation.
À la décision favorable (route gouvernance), `propose_write_file`
part vers ObsiGate par le pont MCP (ACC-2.2) et la distribution est
journalisée (nouveau canal `obsigate` de `meeting_distributions`).
Sans connecteur ObsiGate configuré, l'exécution échoue avec un
message explicite. Pilote : usage réel conditionnel au chantier
Meetings (décision A/B) et à la clé ObsiGate dédiée au pont.
- **ACC-4.4 — panneau de l'agent commun** : intégré depuis la 7.81.0
(panneau v3, conversation commune du service d'agents), complété
par le mode console (7.82.x) et la version du service en sidebar
(7.83.1). Cette version fige l'intégration par des tests de
contrat (`tests/test_fusion_panneau_commun.py`) : assets chargés,
configuration injectée par l'hôte (aucune URL publique codée en
dur), conversation du service — et automatisations locales
conservées (décision C5).
## v7.83.1 (2026-10-26) — Panneau Spark : version du service d'agents visible en mode sidebar
- **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session —
jamais une valeur codée en dur ; service injoignable = rien
d'affiché, aucune erreur visible. Carte fusion #64.
## v7.83.0 (2026-10-26) — Fusion : serveur MCP complété (ACC-5.1) et pont MCP vers ObsiGate (ACC-2.2)
- **ACC-5.1 — serveur MCP Flowdeck complété** (`app/routers/mcp_server.py`,
spec d'accès centralisé §7.2/§7.4). Le catalogue de lecture gagne
`list_projects`, `get_project` (métadonnées + schéma + titres seuls),
`read_page` (page classique ou ligne de collection, contenu tronqué
proprement à 20 000 caractères) et `list_project_tasks` (tâches d'une
base projet, tous assignés). `search_pages` devient une vraie
recherche **plein texte** (titre + contenu des pages, titre +
propriétés des lignes de collection), et chaque résultat porte son
**lien profond** (`url`) et son `kind` — contrat figé : un projet
Flowdeck est une collection. §7.4 tenu : les **scopes du jeton**
sont appliqués (un jeton `read` se voit refuser les outils
d'écriture, erreur -32002) et **chaque appel `tools/call` est
journalisé** dans `api_audit_log` (outil, jeton, statut — jamais les
valeurs d'arguments). `list_meetings`/`get_meeting` restent hors
catalogue tant que la décision Meetings A/B n'est pas tranchée.
- **ACC-2.2 — pont MCP Flowdeck → ObsiGate, côté client**
(`app/services/mcp_client.py`, plan « étape 1 : lectures seules »).
Le client MCP gère désormais les serveurs **à session** (Streamable
HTTP d'ObsiGate) : capture et réémission de l'en-tête
`Mcp-Session-Id`, handshake complet avant `tools/call`, et décodage
SSE **multi-messages** (le message de réponse est sélectionné, pas
le premier `data:`). Nouveau preset de connecteur **« obsigate »**
(URL dérivée de la configuration dev/prod, clé dédiée au pont créée
dans ObsiGate → Configurations → Clés API & MCP).
- **Garde-fou n° 2 du plan du pont** : les outils MCP sont **classés**
à la découverte (annotation `readOnlyHint` du serveur, sinon
heuristique de nom ; lecture par défaut). Un outil MCP écrivant
(`propose_*`, `create_*`…) passe désormais par les mêmes portes que
les outils internes — politique d'approbation (`agent_policies`) et
rôles (`PermissionManager`, via le nouvel `is_write_tool`) — là où
il passait avant pour une lecture. Et un outil distant **`apply_*`
est bloqué côté client** : une écriture croisée ne s'exécute jamais
par un simple appel d'outil ; elle suit le patron §7.5 (propose →
approbation dans l'application propriétaire).
- **Preuve réelle T0–T5** : le code est testé contre un faux serveur
ObsiGate à session stricte (`tests/test_fusion_pont_obsigate.py`,
7 tests ; `tests/test_fusion_mcp_server.py` porté à 18 tests). La
démonstration contre l'ObsiGate réel attend encore deux gestes de
Bruno : créer la clé API ObsiGate dédiée au pont, **limitée à un
seul vault**, et trancher **Q-A/Q-B** (fusion-trois-apps #1).
## v7.82.4 (2026-10-25) — Résolution dev/prod par l'hôte de la requête : le mode console du panneau revit en dev
- **Bug corrigé (dev)** : sur flowdeck-dev, le mode console du
+1 -1
View File
@@ -1 +1 @@
7.82.4
7.84.0
+1 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.82.4"
# version="7.84.0"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
+9
View File
@@ -87,4 +87,13 @@ def decide_approval(approval_id: int, request: Request, body: dict = Body(defaul
raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request)
# Pilote ACC-6.2 : une demande d'archivage de réunion approuvée
# s'exécute ici (proposition d'écriture vers ObsiGate) ; le
# résultat accompagne la réponse, sans changer le contrat de la
# file pour les autres outils.
from app.services import meeting_archive
archive = meeting_archive.traiter_decision_sync(out)
if archive is not None:
return {**out, "archive": archive}
return out
+312 -11
View File
@@ -18,6 +18,21 @@ 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.
**ACC-5.1 (architecture v2.0, §7.2)** — le catalogue de lecture cible
est couvert : ``list_projects`` / ``get_project`` (un « projet » du
contrat de liens profonds est une collection : ``/collections/<id>``),
``search_pages`` (plein texte : titres **et** contenus, pages
classiques et lignes de collections), ``read_page`` (contenu tronqué
proprement) et ``list_project_tasks`` (collections ``is_task``). Les
outils réunions restent absents (décision Meetings A/B ouverte).
Chaque résultat d'objet porte son **URL profonde** (forme figée par
``contrats/liens-profonds.md``), comme le §7.2 l'exige.
**§7.4** — chaque ``tools/call`` est journalisé dans
``api_audit_log`` (jeton, outil, horodatage) et la **portée du
jeton** est appliquée : sans le scope ``write``, les trois outils
d'écriture sont refusés (« jeton en lecture seule »).
"""
from __future__ import annotations
@@ -74,6 +89,41 @@ OUTILS = [
{"name": "list_repos",
"description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "list_projects",
"description": "Liste les PROJETS Flowdeck du périmètre du jeton "
"(catalogue §7.2) : au sens du contrat de liens "
"profonds, un projet est une collection — id, titre, "
"état (is_task, nombre de lignes) et URL profonde "
"/collections/<id>.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_project",
"description": "Détail d'un projet (collection) SANS le contenu "
"complet de ses pages : métadonnées, schéma des "
"propriétés et titres de ses lignes, URL profonde.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "read_page",
"description": "Lit le contenu d'une page (§7.2), tronqué "
"proprement à 20 000 caractères (drapeau "
"« truncated »). « kind » départage les deux "
"familles d'identifiants : « page » (page "
"classique d'un workspace, URL /pages/<id>) ou "
"« collection_page » (ligne d'une collection, "
"contenu porté par sa page liée). Sans « kind », "
"la page classique est essayée d'abord.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"},
"kind": {"type": "string",
"enum": ["page", "collection_page"]}},
"required": ["id"]}},
{"name": "list_project_tasks",
"description": "Liste les tâches des projets « base de tâches » "
"(collections is_task) du périmètre du jeton : "
"titre, statut, échéance, assignés, URL profonde "
"du projet. « project_id » restreint à un projet.",
"inputSchema": {"type": "object",
"properties": {"project_id": {"type": "integer"}}}},
{"name": "create_page",
"description": "Crée une page dans une collection Flowdeck.",
"inputSchema": {"type": "object",
@@ -99,6 +149,38 @@ OUTILS = [
"required": ["name"]}},
]
#: Outils d'écriture — refusés aux jetons sans le scope ``write`` (§7.4).
OUTILS_ECRITURE = {"create_page", "update_page", "create_collection"}
#: Borne de lecture d'un contenu de page (§7.2 : « tronqué proprement »).
CONTENU_MAX = 20000
def _url_projet(collection_id: int) -> str:
"""URL profonde d'un projet (contrat figé : /collections/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/collections/{collection_id}"
def _url_page(page_id: int) -> str:
"""URL profonde d'une page classique (contrat figé : /pages/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/pages/{page_id}"
def _tronquer(texte: str) -> tuple[str, bool]:
"""Troncature propre : jamais au milieu d'un mot si on peut l'éviter."""
texte = texte or ""
if len(texte) <= CONTENU_MAX:
return texte, False
coupe = texte[:CONTENU_MAX]
dernier_espace = coupe.rfind(" ", CONTENU_MAX - 200)
if dernier_espace > 0:
coupe = coupe[:dernier_espace]
return coupe, True
def _props(brut):
"""property_values_json → dict quand c'est du JSON, sinon inchangé."""
@@ -112,8 +194,10 @@ def _props(brut):
def _page_resume(row) -> dict:
return {"id": row["id"], "collection_id": row["collection_id"],
"kind": "collection_page",
"title": row["title"], "icon": row["icon"],
"properties": _props(row["property_values_json"]),
"url": _url_projet(row["collection_id"]),
"updated_at": row["updated_at"]}
@@ -169,6 +253,7 @@ def _appel_outil(nom: str, args: dict, user_id: int):
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,
"url": _url_projet(r["id"]),
"page_count": r["page_count"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
@@ -182,7 +267,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
"ORDER BY position, id", (coll["id"],)).fetchall()
return {"collection": {"id": coll["id"], "name": coll["name"],
"description": coll["description"],
"icon": coll["icon"]},
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"])},
"pages": [_page_resume(p) for p in pages]}
if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
@@ -195,19 +282,181 @@ def _appel_outil(nom: str, args: dict, user_id: int):
res[cle] = p[cle]
return res
if nom == "search_pages":
q = f"%{str(args.get('query', '')).strip()}%"
if q == "%%":
# §7.2 : recherche plein texte — titres ET contenus, pages
# classiques comme lignes de collections, URL profonde et
# famille (« kind ») sur chaque résultat pour que read_page
# puisse être appelé sans ambiguïté d'identifiant.
terme = str(args.get("query", "")).strip()
if not terme:
return []
q = f"%{terme}%"
resultats: list[dict] = []
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"])]
"SELECT * FROM collection_pages "
"WHERE title LIKE ? OR property_values_json LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in rows:
if collection_visible(p["collection_id"]):
resultats.append(_page_resume(p))
pages = conn.execute(
"SELECT id, title, workspace_id, updated_at FROM pages "
"WHERE deleted_at IS NULL AND (title LIKE ? OR content LIKE ?) "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in pages:
if p["workspace_id"] is None or p["workspace_id"] in accessible:
resultats.append({
"id": p["id"], "kind": "page", "title": p["title"],
"workspace_id": p["workspace_id"],
"url": _url_page(p["id"]),
"updated_at": p["updated_at"]})
return resultats[:50]
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 == "list_projects":
# §7.2 : un « projet » du contrat de liens profonds est une
# collection. Même périmètre et même forme que
# list_collections, vocabulaire du catalogue cible.
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"], "title": 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"],
"url": _url_projet(r["id"]),
"updated_at": r["updated_at"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_project":
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": "projet introuvable"}
schema = _props(coll["schema_json"]) \
if coll["schema_json"] else []
titres = conn.execute(
"SELECT id, title FROM collection_pages "
"WHERE collection_id = ? ORDER BY position, id LIMIT 300",
(coll["id"],)).fetchall()
return {"project": {"id": coll["id"], "title": coll["name"],
"description": coll["description"],
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"]),
"updated_at": coll["updated_at"]},
"schema": schema,
"pages": [{"id": t["id"], "title": t["title"]}
for t in titres]}
if nom == "read_page":
try:
pid = int(args.get("id"))
except (TypeError, ValueError):
return {"erreur": "id requis (entier)"}
kind = str(args.get("kind") or "")
if kind in ("page", ""):
page = conn.execute(
"SELECT * FROM pages WHERE id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if page is not None and (
page["workspace_id"] is None
or page["workspace_id"] in accessible):
contenu, tronque = _tronquer(page["content"] or "")
return {"id": page["id"], "kind": "page",
"title": page["title"],
"workspace_id": page["workspace_id"],
"content": contenu, "truncated": tronque,
"url": _url_page(page["id"]),
"updated_at": page["updated_at"]}
if kind == "page":
return {"erreur": "page introuvable"}
if kind in ("collection_page", ""):
p = conn.execute(
"SELECT * FROM collection_pages WHERE id = ?",
(pid,)).fetchone()
if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"}
res = _page_resume(p)
liee = conn.execute(
"SELECT content FROM pages "
"WHERE collection_row_id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if liee is not None:
contenu, tronque = _tronquer(liee["content"] or "")
res["content"] = contenu
res["truncated"] = tronque
return res
return {"erreur": f"kind inconnu : {kind}"}
if nom == "list_project_tasks":
from app.services.task_databases import collect_tasks, is_done
projet_id = args.get("project_id")
if projet_id is not None:
try:
projet_id = int(projet_id)
except (TypeError, ValueError):
return {"erreur": "project_id doit être un entier"}
cible = conn.execute(
"SELECT id, is_task FROM collections WHERE id = ?",
(projet_id,)).fetchone()
if cible is None or not collection_visible(projet_id):
return {"erreur": "projet introuvable"}
if "is_task" in cible.keys() and not cible["is_task"]:
return {"erreur": "ce projet n'est pas une base de tâches"}
taches = collect_tasks(conn, user_id, mine_only=False)
sortie = []
for t in taches:
if projet_id is not None and t["collection_id"] != projet_id:
continue
sortie.append({
"id": t["id"], "title": t["title"],
"status": t["status"], "done": t["done"],
"due": t["due"], "assignees": t["assignees"],
"project_id": t["collection_id"],
"project_name": t["collection_name"],
"url": _url_projet(t["collection_id"])})
if projet_id is not None and not sortie:
# Base de tâches non « configurée » (mapping My Tasks
# incomplet) : collect_tasks la saute par conception,
# mais le serveur MCP répond quand même — lignes brutes,
# statut lu sur la propriété mappée ou nommée Statut.
coll = conn.execute(
"SELECT * FROM collections WHERE id = ?",
(projet_id,)).fetchone()
prop_statut = None
if "task_status_prop" in coll.keys() and coll["task_status_prop"]:
prop_statut = coll["task_status_prop"]
else:
pr = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id = ? AND LOWER(name) IN "
"('statut', 'status', 'état', 'etat') "
"ORDER BY position LIMIT 1", (projet_id,)).fetchone()
if pr is not None:
prop_statut = pr["id"]
lignes = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
"ORDER BY position, id", (projet_id,)).fetchall()
for ligne in lignes:
props = _props(ligne["property_values_json"])
statut = ""
if isinstance(props, dict) and prop_statut is not None:
v = props.get(prop_statut, props.get(str(prop_statut), ""))
if isinstance(v, list):
v = v[0] if v else ""
statut = str(v or "")
sortie.append({
"id": ligne["id"], "title": ligne["title"],
"status": statut, "done": is_done(statut),
"due": "", "assignees": [],
"project_id": projet_id,
"project_name": coll["name"],
"url": _url_projet(projet_id)})
return sortie
if nom == "create_page":
try:
collection_id = int(args.get("collection_id"))
@@ -297,7 +546,8 @@ def _appel_outil(nom: str, args: dict, user_id: int):
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"]}
"description": coll["description"], "icon": coll["icon"],
"url": _url_projet(coll["id"])}
return {"erreur": f"outil inconnu : {nom}"}
@@ -310,11 +560,45 @@ def _version() -> str:
return "dev"
def _scopes_jeton(token_id: int) -> str:
"""Scopes du jeton MCP (colonne ``api_tokens.scopes``, défaut hérité
``read,write`` posé par la migration v6.3.0)."""
try:
with get_conn() as conn:
row = conn.execute("SELECT scopes FROM api_tokens WHERE id = ?",
(token_id,)).fetchone()
if row is not None and "scopes" in row.keys() and row["scopes"]:
return str(row["scopes"])
except Exception: # noqa: BLE001 — base ancienne sans la colonne
pass
return "read,write"
def _journaliser(proprietaire: dict, outil: str, arguments: dict,
request: Request, statut: str) -> None:
"""Journal d'audit §7.4 : qui (jeton), quel outil, quand — les
valeurs d'arguments ne sont pas journalisées, seules leurs clés."""
try:
from app.services.api_v2_helpers import audit_log
cles = ",".join(sorted(str(k) for k in (arguments or {}).keys()))
audit_log({"id": proprietaire["user_id"],
"_token_id": proprietaire["id"]},
f"mcp:{outil}", "mcp_tool", outil,
f"statut={statut}; jeton={proprietaire.get('name', '')}; args=[{cles}]",
request)
except Exception: # noqa: BLE001 — l'audit ne casse jamais l'appel
pass
@router.get("/mcp")
def mcp_info():
noms = [t["name"] for t in OUTILS]
return {"name": "flowdeck", "version": _version(),
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "tools": [t["name"] for t in OUTILS]}
"read_only": False, "tools": noms,
"read_tools": [n for n in noms if n not in OUTILS_ECRITURE],
"write_tools": [n for n in noms if n in OUTILS_ECRITURE]}
@router.post("/mcp")
@@ -349,8 +633,25 @@ async def mcp(request: Request):
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"])
outil = params.get("name", "")
arguments = params.get("arguments") or {}
# §7.4 : la portée du jeton fait la loi — un jeton « read »
# (ex. clé dédiée à un pont de lecture) ne peut pas écrire.
if outil in OUTILS_ECRITURE:
from app.services.api_v2_helpers import has_scope
if not has_scope(_scopes_jeton(proprietaire["id"]), "write"):
_journaliser(proprietaire, outil, arguments, request,
"refuse_portee")
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32002,
"message": "Jeton en lecture seule : "
"l'outil d'écriture "
f"« {outil} » exige le scope write"}}
res = _appel_outil(outil, arguments, proprietaire["user_id"])
_journaliser(proprietaire, outil, arguments, request,
"erreur" if isinstance(res, dict) and "erreur" in res
else "ok")
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}
+22
View File
@@ -396,6 +396,28 @@ def get_transcript_row(transcript_id: int, request: Request):
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/archiver-obsigate",
status_code=201)
def archiver_obsigate(transcript_id: int, request: Request):
"""Pilote ACC-6.2 : dépose l'archivage du résumé dans ObsiGate
dans la file d'approbations unifiée (rien n'est écrit avant
l'approbation ; l'exécution part de la décision, voir
governance.decide_approval)."""
user = _auth_user(request, require_write=True)
from app.services import meeting_archive
try:
approval = meeting_archive.demander_approbation(transcript_id, user["id"])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.archive.request", "transcript", transcript_id,
f"approval #{approval['id']}", request)
archive = meeting_archive.construire_archive(meet.get_transcript(transcript_id))
return {"approval": approval, "titre": archive["titre"],
"chemin": archive["chemin"]}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
+2 -2
View File
@@ -22,7 +22,7 @@ from app.services import agent_memory
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import WRITE_TOOLS, PermissionManager
from app.services.permission_manager import PermissionManager, is_write_tool
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -248,7 +248,7 @@ class AgentEngine:
# v7.2.0 — workspace tool scope + human approval gate, checked
# *before* permissions so a scoped-out tool never reaches ACLs.
gov = check_tool(self.user_id, self.workspace_id, tool,
is_write=tool in WRITE_TOOLS,
is_write=is_write_tool(tool),
conversation_id=conversation_id)
if not gov.get("allowed"):
detail = gov.get("reason") or "Refusé par la politique agent"
+17 -3
View File
@@ -24,7 +24,11 @@ from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak", "obsigate")
#: Kinds personnels qui parlent MCP (probe = handshake + tools/list,
#: outils dynamiques ``mcp_<serveur>_*``) — générique + les deux presets
#: de fédération du programme (TikTak ACC-2.1, ObsiGate ACC-2.2).
MCP_KINDS = ("mcp", "tiktak", "obsigate")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
@@ -45,6 +49,16 @@ def _presets() -> dict:
# v7.82.0 : URL dérivée de la config (TIKTAK_URL ; défaut officiel).
"tiktak": {"url": f"{draco_urls.tiktak_url()}/mcp", "auth": "bearer",
"hint": "TikTak → Réglages → Intégrations → clé API tck_…"},
# Fusion ACC-2.2 — pont MCP vers ObsiGate (plan « étape 1 :
# lectures seules », tests T0–T5) : serveur MCP d'ObsiGate sur
# /mcp (Streamable HTTP à session — le client gère le
# Mcp-Session-Id). La clé se crée dans ObsiGate →
# Configurations → Clés API & MCP, dédiée au pont et limitée
# à UN SEUL vault au départ : c'est le jeton qui fait la
# portée (garde-fou n° 1 du plan), pas ce preset.
"obsigate": {"url": f"{draco_urls.obsigate_url()}/mcp", "auth": "bearer",
"hint": "ObsiGate → Configurations → Clés API & MCP → "
"clé dédiée au pont, limitée à un seul vault"},
}
@@ -293,7 +307,7 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind in ("mcp", "tiktak"):
if kind in MCP_KINDS:
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
@@ -343,7 +357,7 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind in ("mcp", "tiktak"):
if kind in MCP_KINDS:
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
+134 -22
View File
@@ -6,10 +6,30 @@ JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
sans état en mémoire.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
réponse JSON directe, soit la première ligne ``data:`` d'une
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
répondent en JSON chez la grande majorité des serveurs).
**Sessions (ACC-2.2, pont ObsiGate — test T0 du plan d'étape 1).**
Certains serveurs Streamable HTTP (ObsiGate) sont **à session** : ils
renvoient un en-tête ``Mcp-Session-Id`` à ``initialize`` et refusent
tout appel qui ne le réémet pas. Le client capture donc cet en-tête à
chaque réponse et le réémet sur les appels suivants de la même
opération ; ``call_tool`` fait désormais le handshake complet avant
``tools/call`` (les serveurs sans état comme TikTak l'acceptent sans
frais). Réponses **JSON ou SSE** : dans un flux ``text/event-stream``
qui porte plusieurs messages (notifications + réponse), c'est le
message **de réponse** (id correspondant, sinon porteur de
``result``/``error``) qui est retenu — pas le premier ``data:`` venu.
**Classification des outils (garde-fou n° 2 du plan du pont).** Chaque
outil découvert est classé au moment du cache : **lecture par défaut** ;
écrivant si le serveur le déclare (annotation ``readOnlyHint: false``)
ou si son nom porte un verbe d'écriture (``create_``, ``propose_``,
``apply_``…). La classe est consommée par les portes de gouvernance
(``agent_policies.check_tool`` et ``PermissionManager`` via
``permission_manager.is_write_tool``) : un outil MCP écrivant suit le
même régime d'approbation et de rôles que les outils internes.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; pas de
client SSE à l'état (les méthodes utilisées répondent en JSON ou en
événements ponctuels chez les serveurs du programme).
"""
from __future__ import annotations
@@ -19,7 +39,7 @@ import logging
import re
from app.db import get_conn
from app.services.connectors import _headers, _with_secret, get
from app.services.connectors import MCP_KINDS, _headers, _with_secret, get
logger = logging.getLogger(__name__)
@@ -27,6 +47,36 @@ logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2024-11-05"
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
#: Verbes d'écriture reconnus en tête d'un nom d'outil MCP quand le
#: serveur ne déclare pas d'annotation ``readOnlyHint``. ``propose_`` /
#: ``apply_`` sont le patron d'écriture approuvée d'ObsiGate : les deux
#: sont classés écrivants (voir aussi le blocage d'``apply_`` dans
#: ``tool_registry.McpTool``).
_PREFIXES_ECRITURE = (
"create_", "update_", "delete_", "add_", "remove_", "set_", "move_",
"write_", "edit_", "rename_", "propose_", "apply_", "archive_",
"unarchive_", "send_", "publish_", "close_", "sync_", "trigger_",
"import_",
)
#: Derniers ``Mcp-Session-Id`` capturés, par URL de serveur. Mémoire de
#: processus volontairement éphémère : une session perdue se rouvre par
#: un handshake, jamais par une persistance.
_sessions: dict[str, str] = {}
def classer_outil(nom_original: str, annotations: dict | None = None) -> bool:
"""True si l'outil MCP est **écrivant**.
L'annotation du serveur fait foi quand elle existe
(``readOnlyHint``) ; sinon, heuristique de nom. Lecture par défaut —
c'est la règle du plan du pont : un outil ne devient écrivant que
s'il est déclaré comme tel.
"""
if isinstance(annotations, dict) and "readOnlyHint" in annotations:
return not bool(annotations["readOnlyHint"])
return (nom_original or "").lower().startswith(_PREFIXES_ECRITURE)
def tool_name(server_name: str, tool_name: str) -> str:
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
@@ -36,14 +86,37 @@ def tool_name(server_name: str, tool_name: str) -> str:
return re.sub(r"_{2,}", "_", joined)
def parse_body(content_type: str, body: str, status: int) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
def parse_body(content_type: str, body: str, status: int,
expected_id=None) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événements ``data:`` SSE).
Dans un flux SSE multi-messages, le message retenu est celui dont
l'``id`` correspond à la requête (``expected_id``), sinon le
premier porteur de ``result``/``error``, sinon le premier message.
"""
text = body or ""
data = None
if "text/event-stream" in (content_type or ""):
candidats: list = []
for line in text.splitlines():
if line.startswith("data:"):
text = line[5:].strip()
break
try:
candidats.append(json.loads(line[5:].strip()))
except ValueError:
continue
if expected_id is not None:
data = next((c for c in candidats
if isinstance(c, dict) and c.get("id") == expected_id),
None)
if data is None:
data = next((c for c in candidats
if isinstance(c, dict)
and ("result" in c or "error" in c)), None)
if data is None and candidats:
data = candidats[0]
if data is None:
raise ValueError(f"Réponse MCP illisible (HTTP {status})")
else:
try:
data = json.loads(text)
except ValueError as exc:
@@ -54,6 +127,25 @@ def parse_body(content_type: str, body: str, status: int) -> dict:
return data if isinstance(data, dict) else {}
def _capturer_session(url: str, headers) -> None:
"""Mémorise le ``Mcp-Session-Id`` d'une réponse, s'il est présent."""
try:
sid = headers.get("mcp-session-id") if headers else None
except AttributeError:
sid = None
if sid:
_sessions[url] = sid
def _session_headers(url: str, headers: dict | None) -> dict:
"""En-têtes d'appel + session connue pour cette URL (si capturée)."""
out = dict(headers or {})
sid = _sessions.get(url)
if sid and not any(k.lower() == "mcp-session-id" for k in out):
out["Mcp-Session-Id"] = sid
return out
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
from app.services.http_client import shared_client
@@ -65,8 +157,9 @@ async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
**(headers or {})}
async with shared_client(timeout=15, headers=hdrs) as client:
resp = await client.post(safe, json=payload)
_capturer_session(url, resp.headers)
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
resp.status_code)
resp.status_code, expected_id=payload.get("id"))
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
@@ -77,11 +170,30 @@ async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
logger.debug("MCP notification ignorée: %s", exc)
async def _handshake(url: str, headers: dict | None) -> dict:
"""``initialize`` + ``notifications/initialized`` → en-têtes de session.
Obligatoire avant tout ``tools/call`` contre un serveur à session
(ObsiGate) ; sans effet de bord contre un serveur sans état. Les
en-têtes retournés portent le ``Mcp-Session-Id`` capturé pendant
``initialize`` quand le serveur en a émis un.
"""
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, headers)
session = _session_headers(url, headers)
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
session)
return session
def _server(connector_id: int) -> dict:
row = get(connector_id)
if row is None:
raise ValueError("Serveur MCP introuvable")
if row["kind"] not in ("mcp", "tiktak"):
if row["kind"] not in MCP_KINDS:
raise ValueError("Ce connecteur n'est pas un serveur MCP")
if not row["enabled"]:
raise ValueError("Serveur MCP désactivé")
@@ -102,6 +214,7 @@ def _normalize(server: dict, raw: list) -> list[dict]:
"original": str(t["name"]),
"description": str(t.get("description") or "Outil MCP"),
"parameters": params,
"write": classer_outil(str(t["name"]), t.get("annotations")),
})
return out
@@ -117,15 +230,9 @@ async def initialize_and_list(connector_id: int) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, srv["headers"])
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
srv["headers"])
session = await _handshake(url, srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, srv["headers"])
"params": {}}, session)
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = _normalize(srv, result.get("tools") or [])
_save_tools(connector_id, tools)
@@ -137,7 +244,7 @@ def cached_tools() -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, tools_json FROM agent_connectors "
"WHERE kind IN ('mcp', 'tiktak') AND enabled=1"
"WHERE kind IN ('mcp', 'tiktak', 'obsigate') AND enabled=1"
).fetchall()
out: list[dict] = []
for r in rows:
@@ -170,13 +277,18 @@ def _content_text(result: dict) -> str:
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
Le handshake (avec session éventuelle) précède systématiquement
l'appel : c'est la condition T0 des serveurs à session.
"""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
session = await _handshake(url, srv["headers"])
data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool, "arguments": arguments or {}},
}, srv["headers"])
}, session)
result = data.get("result") if isinstance(data.get("result"), dict) else {}
text = _content_text(result)[:20000]
return {"status": "error" if result.get("isError") else "ok", "text": text}
+166
View File
@@ -0,0 +1,166 @@
"""Pilote ACC-6.2 — archiver un résumé de réunion Flowdeck dans
ObsiGate, **via la file d'approbations unifiée**.
Chaîne du pilote (patron d'écriture approuvée §7.5) :
1. ``demander_approbation`` — depuis la note de réunion (transcript
avec résumé généré), on prépare l'archive (titre, chemin cible
dans le vault, contenu Markdown) et on dépose une demande dans
``agent_approvals`` (outil ``archiver_reunion_obsigate``) : rien
n'est écrit tant que Bruno n'a pas approuvé.
2. À l'approbation (route gouvernance ``…/agent-approvals/{id}/decide``
qui appelle ``traiter_decision``), ``executer`` envoie le contenu à
ObsiGate par le pont MCP (ACC-2.2) en ``propose_write_file`` —
jamais en écriture directe : ObsiGate garde son propre brouillon,
l'application propriétaire tranche l'application finale.
3. La distribution est journalisée dans ``meeting_distributions``
(canal ``obsigate``), comme les autres canaux de diffusion.
Prérequis réels (gestes de Bruno, inchangés) : connecteur
« obsigate » configuré et sondé dans Flowdeck (clé ObsiGate dédiée
au pont, limitée à un vault) — sans lui, l'exécution échoue avec un
message explicite, la demande reste tracée. Le pilote est
**conditionnel au chantier Meetings** (décision A/B de Bruno) pour
son usage réel ; le code n'en dépend pas : il archive le résumé
d'un transcript existant.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.db import get_conn
from app.services import draco_urls, meetings
logger = logging.getLogger(__name__)
#: Nom d'« outil » porté par les demandes d'approbation du pilote
#: dans la file unifiée (`agent_approvals`).
OUTIL = "archiver_reunion_obsigate"
#: Dossier cible dans le vault ObsiGate.
DOSSIER = "Réunions"
def _assainir(nom: str) -> str:
nom = re.sub(r"[\\/:*?\"<>|]+", " ", nom or "")
nom = re.sub(r"\s+", " ", nom).strip().strip(".")
return nom[:80] or "Réunion sans titre"
def construire_archive(tr: dict) -> dict:
"""Transcript (avec résumé) → {titre, chemin, contenu} de l'archive."""
titre = (tr.get("generated_title") or "").strip() or "Réunion"
date = str(tr.get("created_at") or "")[:10]
chemin = f"{DOSSIER}/{date + ' - ' if date else ''}{_assainir(titre)}.md"
lien = f"{draco_urls.flowdeck_url()}/pages/{tr['page_id']}"
contenu = (
f"# {titre}\n\n"
f"> Résumé de réunion archivé depuis Flowdeck "
f"({date or 'date inconnue'}) — note source : {lien}\n\n"
f"{(tr.get('summary') or '').strip()}\n"
)
return {"titre": titre, "chemin": chemin, "contenu": contenu}
def demander_approbation(transcript_id: int, user_id: int) -> dict:
"""Dépose la demande d'archivage dans la file d'approbations.
Lève ValueError si le transcript n'existe pas, si son résumé est
vide (rien à archiver) ou si une demande identique est déjà en
attente (pas de doublon dans la file).
"""
tr = meetings.get_transcript(transcript_id)
if not (tr.get("summary") or "").strip():
raise ValueError("Résumé vide : générer le résumé avant d'archiver")
archive = construire_archive(tr)
args = {"transcript_id": transcript_id, "chemin": archive["chemin"],
"titre": archive["titre"]}
with get_conn() as conn:
en_attente = conn.execute(
"SELECT id, args_json FROM agent_approvals "
"WHERE tool=? AND status='pending'", (OUTIL,)).fetchall()
for row in en_attente:
try:
if json.loads(row["args_json"] or "{}").get("transcript_id") == transcript_id:
raise ValueError(
"Une demande d'archivage est déjà en attente "
f"(approbation #{row['id']})")
except json.JSONDecodeError:
continue
cur = conn.execute(
"INSERT INTO agent_approvals "
"(conversation_id, tool, args_json, status, requester_id) "
"VALUES (0, ?, ?, 'pending', ?)",
(OUTIL, json.dumps(args, ensure_ascii=False), user_id))
conn.commit()
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(cur.lastrowid,)).fetchone()
return dict(row)
def _connecteur_obsigate() -> dict | None:
"""Premier connecteur ObsiGate activé (preset ACC-2.2), ou None."""
from app.services import connectors
for row in connectors.list_connectors():
if row.get("kind") == "obsigate" and row.get("enabled"):
return row
return None
async def executer(approval: dict) -> dict:
"""Exécute un archivage **approuvé** : propose_write_file via le
pont MCP. Retourne {status: ok|erreur, detail} — ne lève pas."""
from app.services import mcp_client
try:
args = json.loads(approval.get("args_json") or "{}")
except json.JSONDecodeError:
args = {}
transcript_id = args.get("transcript_id")
conn = _connecteur_obsigate()
if conn is None:
return {"status": "erreur",
"detail": "Connecteur ObsiGate non configuré dans Flowdeck "
"(Panneau agent → Connecteurs → ObsiGate) : "
"archivage impossible."}
try:
tr = meetings.get_transcript(int(transcript_id))
archive = construire_archive(tr)
res = await mcp_client.call_tool(
conn["id"], "propose_write_file",
{"path": archive["chemin"], "content": archive["contenu"]})
except Exception as exc: # noqa: BLE001 — l'échec est journalisé, pas levé
logger.warning("Archivage réunion ObsiGate échoué: %s", exc)
return {"status": "erreur", "detail": str(exc)[:300]}
if res.get("status") != "ok":
return {"status": "erreur",
"detail": (res.get("text") or "ObsiGate a refusé la proposition")[:300]}
meetings.record_distribution(
int(transcript_id), channel="obsigate",
actor_id=approval.get("approver_id"),
recipients=archive["chemin"], status="propose")
return {"status": "ok",
"detail": f"Proposition d'écriture envoyée à ObsiGate : "
f"{archive['chemin']} (brouillon à appliquer dans ObsiGate)"}
def executer_sync(approval: dict) -> dict:
"""Variante synchrone pour les routes non async (threadpool)."""
return asyncio.run(executer(approval))
def traiter_decision_sync(approval: dict) -> dict | None:
"""Après décision dans la file unifiée : si la demande est du
pilote et approuvée, exécuter l'archivage. Sinon None (autre
outil) — la route gouvernance est synchrone (threadpool)."""
if not approval or approval.get("tool") != OUTIL:
return None
if approval.get("status") != "approved":
return {"status": "rejete",
"detail": "Demande rejetée : rien n'a été envoyé à ObsiGate."}
return executer_sync(approval)
+2 -2
View File
@@ -717,8 +717,8 @@ async def summarize_transcript(transcript_id: int, user_id: int | None = None,
def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"):
raise ValueError("channel must be copy_link|email|slack")
if channel not in ("copy_link", "email", "slack", "obsigate"):
raise ValueError("channel must be copy_link|email|slack|obsigate")
get_transcript(transcript_id)
with get_conn() as conn:
cur = conn.execute(
+23 -1
View File
@@ -55,6 +55,28 @@ DESTRUCTIVE_TOOLS = {
}
def is_write_tool(tool: str) -> bool:
"""L'outil est-il écrivant ? Outils internes (``WRITE_TOOLS``) et
outils **MCP dynamiques** classés écrivants à la découverte
(``mcp_client.classer_outil`` — garde-fou n° 2 du pont ObsiGate,
ACC-2.2 : avant, un ``mcp_*`` passait toujours pour une lecture,
donc un ``apply_*`` distant échappait aux deux portes).
"""
if tool in WRITE_TOOLS:
return True
if tool.startswith("mcp_"):
try:
from app.services import mcp_client
for entry in mcp_client.cached_tools():
if entry.get("name") == tool:
return bool(entry.get("write"))
except Exception: # noqa: BLE001 — la classification ne casse rien
logger.debug("is_write_tool: classification MCP indisponible",
exc_info=True)
return False
class PermissionManager:
"""Resolves workspace role and gates agent + granular ACL checks."""
@@ -186,7 +208,7 @@ class PermissionManager:
- destructive tools: admin/owner, or requires confirm approval mode.
"""
role = self.role_in_workspace(workspace_id)
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
if is_write_tool(tool) and role not in WRITE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
+218
View File
@@ -0,0 +1,218 @@
"""Format de skill commun (Fusion ACC-0.3) — lecture, validation,
conversion (ACC-4.5).
Le contrat figé vit dans `dracodev-commons/contrats/skill-format.md` :
un dossier par skill, un `SKILL.md` à frontmatter YAML (`name`,
`description` = déclencheur, `version`, `apps`, `domaines`, `outils`,
section `ecritures` obligatoire dès qu'il y a modification) et un
corps en trois sections ordonnées — **Quand l'utiliser**,
**Procédure**, **Garde-fous**.
Ce module est la part Flowdeck de la preuve du format (ACC-4.5) :
- ``analyser_skill_md`` : SKILL.md → structure (métadonnées + sections) ;
- ``valider`` : contrôle du contrat, liste d'erreurs lisibles ;
- ``vers_champs_flowdeck`` : conversion vers les champs d'un skill
Flowdeck (`agent_skills` / galerie) — les outils `flowdeck.*`
deviennent les `allowed_tools`, la Procédure devient le
`prompt_template` (précédée du déclencheur et suivie des garde-fous) ;
- ``depuis_preset_flowdeck`` : conversion inverse d'un preset de la
galerie vers un SKILL.md au format commun (point de départ d'une
conversion relue par un humain).
Les skills convertis de la galerie vivent dans `skills-communs/` à la
racine du dépôt ; ils sont installables tels quels via
``skill_gallery.upsert_skill(vers_champs_flowdeck(...))``.
"""
from __future__ import annotations
import re
import yaml
#: Sections du corps, dans l'ordre imposé par le contrat.
SECTIONS = ("Quand l'utiliser", "Procédure", "Garde-fous")
#: Valeurs admises pour l'approbation d'une écriture.
APPROBATIONS = ("toujours", "selon-portee", "jamais")
#: Champs de frontmatter obligatoires (contrat § « En-tête »).
CHAMPS_REQUIS = ("name", "description", "version", "apps", "domaines")
_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
_TITRE_SECTION = re.compile(r"^#{1,3}\s+(.+?)\s*$", re.MULTILINE)
def analyser_skill_md(texte: str) -> dict:
"""SKILL.md → {meta, sections, corps}. Lève ValueError si le
frontmatter est absent ou illisible."""
brut = (texte or "").replace("\r\n", "\n")
if not brut.startswith("---\n"):
raise ValueError("SKILL.md doit commencer par un frontmatter YAML (---)")
fin = brut.find("\n---", 4)
if fin < 0:
raise ValueError("Frontmatter YAML non fermé (---)")
try:
meta = yaml.safe_load(brut[4:fin]) or {}
except yaml.YAMLError as exc:
raise ValueError(f"Frontmatter YAML illisible : {exc}") from exc
if not isinstance(meta, dict):
raise ValueError("Frontmatter YAML : un objet est attendu")
corps = brut[fin + 4:].lstrip("\n")
sections: dict[str, str] = {}
marques = [(m.start(), m.group(1).strip()) for m in _TITRE_SECTION.finditer(corps)]
for i, (pos, titre) in enumerate(marques):
if titre in SECTIONS:
debut = corps.find("\n", pos) + 1
fin_s = marques[i + 1][0] if i + 1 < len(marques) else len(corps)
sections[titre] = corps[debut:fin_s].strip()
return {"meta": meta, "sections": sections, "corps": corps}
def _outils_flowdeck(skill: dict) -> list[str]:
"""Noms d'outils Flowdeck déclarés (préfixe `flowdeck.` retiré),
lectures (`outils`) puis écritures (`ecritures`), sans doublons."""
meta = skill["meta"]
noms: list[str] = []
for entree in meta.get("outils") or []:
nom = str(entree).strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
for entree in meta.get("ecritures") or []:
if isinstance(entree, dict):
nom = str(entree.get("action") or "").strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
return list(dict.fromkeys(noms))
def valider(skill: dict) -> list[str]:
"""Contrôle le contrat ACC-0.3 → liste d'erreurs (vide = conforme)."""
erreurs: list[str] = []
meta = skill.get("meta") or {}
for champ in CHAMPS_REQUIS:
valeur = meta.get(champ)
if valeur is None or (isinstance(valeur, str) and not valeur.strip()) \
or (isinstance(valeur, list) and not valeur):
erreurs.append(f"Champ obligatoire manquant ou vide : {champ}")
nom = str(meta.get("name") or "")
if nom and not _KEBAB.match(nom):
erreurs.append(f"name doit être en kebab-case : {nom!r}")
for champ in ("apps", "domaines"):
valeur = meta.get(champ)
if valeur is not None and (not isinstance(valeur, list)
or not all(isinstance(v, str) for v in valeur)):
erreurs.append(f"{champ} doit être une liste de chaînes")
outils = meta.get("outils") or []
if not isinstance(outils, list) or not all(isinstance(o, str) for o in outils):
erreurs.append("outils doit être une liste de noms (app.outil)")
ecritures = meta.get("ecritures") or []
if not isinstance(ecritures, list):
erreurs.append("ecritures doit être une liste d'objets {action, approbation}")
ecritures = []
for entree in ecritures:
if not isinstance(entree, dict) or not str(entree.get("action") or "").strip():
erreurs.append("Chaque écriture doit déclarer une action")
continue
approbation = str(entree.get("approbation") or "selon-portee")
if approbation not in APPROBATIONS:
erreurs.append(
f"approbation inconnue pour {entree['action']} : {approbation!r} "
f"(attendu : {', '.join(APPROBATIONS)})")
# Règle du contrat : « ecritures » obligatoire dès que le skill
# modifie quelque chose — pour la part Flowdeck, un outil déclaré
# qui est un outil d'écriture interne impose la section.
if not ecritures and "flowdeck" in (meta.get("apps") or []):
from app.services.permission_manager import DESTRUCTIVE_TOOLS, WRITE_TOOLS
modifiants = [o for o in _outils_flowdeck(skill)
if o in WRITE_TOOLS or o in DESTRUCTIVE_TOOLS]
if modifiants:
erreurs.append(
"Section ecritures obligatoire : le skill utilise des outils "
f"d'écriture Flowdeck ({', '.join(modifiants)})")
sections = skill.get("sections") or {}
for titre in SECTIONS:
if not sections.get(titre):
erreurs.append(f"Section de corps manquante ou vide : « {titre} »")
return erreurs
def vers_champs_flowdeck(skill: dict) -> dict:
"""Skill au format commun → champs Flowdeck (`agent_skills`).
Le `prompt_template` assemble le déclencheur, la procédure et les
garde-fous ; les écritures déclarées restent visibles dans le
prompt (le skill ne s'exécute jamais sans la politique d'agent).
"""
erreurs = valider(skill)
if erreurs:
raise ValueError("Skill non conforme au format commun : " + " ; ".join(erreurs))
meta = skill["meta"]
sections = skill["sections"]
morceaux = [
"Quand l'utiliser\n" + sections["Quand l'utiliser"],
"Procédure\n" + sections["Procédure"],
"Garde-fous\n" + sections["Garde-fous"],
]
ecritures = meta.get("ecritures") or []
if ecritures:
lignes = "\n".join(
f"- {e['action']} : approbation "
f"{e.get('approbation') or 'selon-portee'}"
for e in ecritures)
morceaux.append(f"Écritures déclarées (format commun)\n{lignes}")
return {
"name": str(meta["name"]),
"description": str(meta["description"]).strip(),
"prompt_template": "\n\n".join(morceaux),
"allowed_tools": _outils_flowdeck(skill),
}
def _slug(nom: str) -> str:
import unicodedata
plat = unicodedata.normalize("NFD", nom.lower())
plat = "".join(c for c in plat if unicodedata.category(c) != "Mn")
slug = re.sub(r"[^a-z0-9]+", "-", plat).strip("-")
return slug or "skill"
def depuis_preset_flowdeck(preset: dict, *, domaines: list[str] | None = None) -> str:
"""Preset de la galerie Flowdeck → SKILL.md au format commun.
Point de départ d'une conversion (ACC-4.5) : les outils
d'écriture du preset deviennent la section `ecritures`
(approbation `selon-portee`, le régime par défaut du contrat) ;
le texte est à relire par un humain avant diffusion.
"""
from app.services.permission_manager import WRITE_TOOLS
outils = [str(o) for o in preset.get("allowed_tools") or []]
lectures = [o for o in outils if o not in WRITE_TOOLS]
ecritures = [o for o in outils if o in WRITE_TOOLS]
meta: dict = {
"name": _slug(str(preset.get("name") or "skill")),
"description": str(preset.get("description") or "").strip(),
"version": "1.0.0",
"apps": ["flowdeck"],
"domaines": domaines or ["pro"],
"outils": [f"flowdeck.{o}" for o in lectures],
}
if ecritures:
meta["ecritures"] = [
{"action": f"flowdeck.{o}", "approbation": "selon-portee"}
for o in ecritures
]
front = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
corps = (
f"## Quand l'utiliser\n\n{preset.get('description') or ''}\n\n"
f"## Procédure\n\n{preset.get('prompt_template') or ''}\n\n"
"## Garde-fous\n\n"
"- N'agir que dans les domaines déclarés ; un besoin croisé "
"entre domaines est signalé, jamais contourné.\n"
"- Ne jamais inventer un contenu absent des sources lues.\n"
)
return f"---\n{front}\n---\n\n{corps}"
+15 -1
View File
@@ -1230,9 +1230,23 @@ class McpTool(Tool):
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import mcp_client
# Garde-fou du pont (ACC-2.2, patron §7.5) : un outil distant
# « apply_* » EXÉCUTE une écriture croisée — il n'est jamais
# utilisable par un simple appel d'outil depuis un agent
# Flowdeck. L'écriture passe par « propose_* » (classé
# écrivant : rôles + approbation) puis par l'approbation dans
# l'application propriétaire de la donnée.
original = self.entry.get("original") or ""
if original.lower().startswith("apply_"):
return ToolResult(
status="error", tool=self.name, target_type="mcp",
message=(f"Outil « {original} » bloqué côté client : une "
"écriture croisée ne s'exécute pas par un appel "
"d'outil — propose_* puis approbation dans "
"l'application propriétaire (patron §7.5)."))
try:
res = await mcp_client.call_tool(self.entry["connector_id"],
self.entry.get("original") or "", args)
original, args)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
+2 -1
View File
@@ -452,6 +452,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
<option value="tiktak">TikTak — tâches (MCP, Fusion)</option>
<option value="obsigate">ObsiGate — notes (MCP, Fusion, pont lecture)</option>
</select>
<label for="fd-plus-cn-name">Nom</label>
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
@@ -533,5 +534,5 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</div>
</div>
<script nonce="{{ csp_nonce() }}">window.FLOWDECK_TIKTAK_MCP_URL = {{ (draco_urls().tiktak + "/mcp") | tojson }};</script>
<script nonce="{{ csp_nonce() }}">window.FLOWDECK_TIKTAK_MCP_URL = {{ (draco_urls().tiktak + "/mcp") | tojson }};window.FLOWDECK_OBSIGATE_MCP_URL = {{ (draco_urls().obsigate + "/mcp") | tojson }};</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/agent_panel_2.js?v={{ asset_version }}"></script>
@@ -0,0 +1,46 @@
---
name: compte-rendu-reunion
description: >
Transforme une note brute de réunion en compte rendu structuré
(décisions et actions) dans Flowdeck. À utiliser quand Bruno demande
un compte rendu, un CR ou une synthèse décisionnelle à partir d'une
note de réunion.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno fournit une note de réunion brute — ou demande de la
retrouver dans le workspace — et veut un compte rendu propre :
décisions prises, actions avec responsable et échéance, points
ouverts. Ne pas l'utiliser pour un simple résumé informatif sans
décisions (voir `resume-document`).
## Procédure
1. Retrouver la note de réunion si elle n'est pas fournie
(search_workspace), puis la lire intégralement (read_document).
2. Créer un document « CR — <titre> » (create_document).
3. Y écrire (write_blocks) la structure : Contexte · Décisions
prises · Actions (avec responsable et échéance) · Points ouverts.
4. Ne garder que ce qui est dans la note ; lister explicitement les
points manquants plutôt que de les combler.
## Garde-fous
- Aucune décision ni action inventée : tout vient de la note lue.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture du document suit la politique d'approbation de l'agent
(section `ecritures`) : le contenu exact est présenté avant
exécution quand la portée l'exige.
+46
View File
@@ -0,0 +1,46 @@
---
name: rapport-hebdo
description: >
Agrège les pages et documents modifiés de la semaine dans Flowdeck
et rédige un rapport structuré de l'équipe. À utiliser quand Bruno
demande le rapport hebdomadaire, un bilan de semaine ou l'état
d'avancement de l'équipe.
version: 1.0.0
apps: [flowdeck]
domaines: [pro]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno demande le rapport de la semaine : ce qui a avancé, ce
qui bloque, ce qui a été décidé, et le plan de la semaine suivante.
Ne pas l'utiliser pour une seule réunion (voir
`compte-rendu-reunion`) ni pour un document unique (voir
`resume-document`).
## Procédure
1. Repérer les pages et documents modifiés cette semaine
(search_workspace).
2. Lire les plus significatifs (read_document) et en extraire
avancées, blocages et décisions.
3. Créer un document « Rapport hebdo — <date> » (create_document) et
y écrire (write_blocks) la structure : Résumé · Faits marquants ·
Blocages · Plan de la semaine prochaine.
## Garde-fous
- Chaque affirmation s'appuie sur un document lu, jamais sur une
supposition ; une section sans élément le dit explicitement.
- Domaine professionnel uniquement (domaine déclaré) : ne pas
mélanger des contenus famille ou perso au rapport d'équipe.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+45
View File
@@ -0,0 +1,45 @@
---
name: resume-document
description: >
Résume un document long de Flowdeck en une page : points clés,
chiffres marquants, décisions et questions ouvertes. À utiliser
quand Bruno demande de résumer, condenser ou synthétiser un
document précis.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno désigne un document — ou demande de le retrouver — et
veut l'essentiel en une page. Ne pas l'utiliser pour agréger
plusieurs documents sur une période (voir `rapport-hebdo`) ni pour
produire un compte rendu décisionnel de réunion (voir
`compte-rendu-reunion`).
## Procédure
1. Retrouver le document s'il n'est pas fourni (search_workspace),
puis le lire intégralement (read_document).
2. Créer un document « Résumé — <titre> » (create_document).
3. Y écrire (write_blocks) : 5 points clés, chiffres marquants,
décisions/engagements, questions restées ouvertes. Maximum une
page, phrases courtes.
## Garde-fous
- Aucune reformulation qui change le sens ; en cas de doute,
citer le passage plutôt que l'interpréter.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+3 -1
View File
@@ -1425,7 +1425,9 @@
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'},
tiktak: {url:(window.FLOWDECK_TIKTAK_MCP_URL || 'https://tiktak.dracodev.net/mcp'), auth:'bearer', name:'TikTak',
hint:'TikTak → Réglages → Intégrations → clé API tck_…'}
hint:'TikTak → Réglages → Intégrations → clé API tck_…'},
obsigate:{url:(window.FLOWDECK_OBSIGATE_MCP_URL || 'https://og.dracodev.net/mcp'), auth:'bearer', name:'ObsiGate',
hint:'ObsiGate → Configurations → Clés API & MCP → clé dédiée au pont, limitée à un seul vault'}
};
var p = presets[f.kind] || presets.custom;
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
+33
View File
@@ -46,6 +46,7 @@
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -274,6 +275,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <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 class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -282,6 +284,7 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -336,6 +339,35 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
@@ -812,6 +844,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+78
View File
@@ -0,0 +1,78 @@
"""Fusion ACC-4.5 — conversion d'Agents & Skills Flowdeck au format
commun (contrat ACC-0.3, `dracodev-commons/contrats/skill-format.md`).
Preuve du format : trois presets de la galerie Flowdeck convertis en
`skills-communs/<nom>/SKILL.md` (compte-rendu-reunion, rapport-hebdo,
resume-document). Ils doivent être conformes au contrat, se convertir
en champs Flowdeck et s'installer dans un workspace via le chemin
existant (`skill_gallery.upsert_skill`).
"""
from pathlib import Path
import pytest
from app.services import skill_format, skill_gallery
RACINE = Path(__file__).resolve().parent.parent
CONVERTIS = ["compte-rendu-reunion", "rapport-hebdo", "resume-document"]
def _lire(nom: str) -> str:
return (RACINE / "skills-communs" / nom / "SKILL.md").read_text(encoding="utf-8")
def test_trois_skills_convertis_conformes():
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
assert skill_format.valider(skill) == [], (nom, skill_format.valider(skill))
assert skill["meta"]["name"] == nom
assert skill["meta"]["apps"] == ["flowdeck"]
assert skill["meta"]["version"] == "1.0.0"
# Toute écriture est déclarée, avec approbation explicite.
actions = {e["action"] for e in skill["meta"]["ecritures"]}
assert actions == {"flowdeck.create_document", "flowdeck.write_blocks"}
def test_conversion_vers_flowdeck_et_installation(client):
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == nom
assert set(champs["allowed_tools"]) == {
"search_workspace", "read_document", "create_document", "write_blocks"}
assert "Procédure" in champs["prompt_template"]
assert "Garde-fous" in champs["prompt_template"]
row, cree = skill_gallery.upsert_skill(
champs, workspace_id=None, created_by=1)
assert cree is True and row["name"] == nom
def test_validation_rejette_les_ecarts():
incomplet = skill_format.analyser_skill_md(
"---\nname: mauvais nom\napps: [flowdeck]\n---\n\n## Procédure\n\nx\n")
erreurs = skill_format.valider(incomplet)
assert any("kebab" in e for e in erreurs)
assert any("description" in e for e in erreurs)
assert any("Quand l'utiliser" in e for e in erreurs)
with pytest.raises(ValueError):
skill_format.vers_champs_flowdeck(incomplet)
def test_ecritures_obligatoires_si_outil_ecrivant():
texte = (
"---\nname: supprime-tout\ndescription: x\nversion: 1.0.0\n"
"apps: [flowdeck]\ndomaines: [pro]\noutils: [flowdeck.delete_page]\n---\n\n"
"## Quand l'utiliser\n\nx\n\n## Procédure\n\nx\n\n## Garde-fous\n\nx\n")
erreurs = skill_format.valider(skill_format.analyser_skill_md(texte))
assert any("ecritures obligatoire" in e for e in erreurs)
def test_aller_retour_depuis_un_preset():
preset = skill_gallery.get_gallery("compte-rendu-reunion")
texte = skill_format.depuis_preset_flowdeck(preset, domaines=["pro"])
skill = skill_format.analyser_skill_md(texte)
assert skill_format.valider(skill) == []
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == "compte-rendu-de-reunion"
assert "create_document" in champs["allowed_tools"]
+167 -3
View File
@@ -70,8 +70,9 @@ def test_mcp_handshake_et_outils(client, jeton):
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_workspaces", "get_workspace",
"list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
"search_pages", "list_repos", "list_projects",
"get_project", "read_page", "list_project_tasks",
"create_page", "update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
@@ -107,7 +108,13 @@ def test_mcp_collections_et_pages(client, jeton, donnees):
assert titres == ["Page de cadrage", "Revue hebdo"]
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
res = _appel(client, jeton, 3, "search_pages", {"query": "cadrage"})
assert [p["title"] for p in res] == ["Page de cadrage"]
# ACC-5.1 : la recherche est plein texte et couvre aussi les pages
# classiques — on vérifie la ligne de collection, sa famille et son
# URL profonde (§7.2 : l'URL est systématique).
lignes = [p for p in res if p["kind"] == "collection_page"]
assert [p["title"] for p in lignes] == ["Page de cadrage"]
assert all(p["url"].endswith(f"/collections/{donnees}") for p in lignes)
assert all("url" in p and "kind" in p for p in res)
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
assert "introuvable" in res["erreur"]
@@ -237,3 +244,160 @@ def test_mcp_info_get(client, jeton):
assert "create_page" in infos["tools"]
assert "update_page" in infos["tools"]
assert "create_collection" in infos["tools"]
# ACC-5.1 : le catalogue distingue lectures et écritures.
assert "list_projects" in infos["read_tools"]
assert "read_page" in infos["read_tools"]
assert infos["write_tools"] == ["create_page", "update_page",
"create_collection"]
# ── ACC-5.1 : catalogue de lecture §7.2 ────────────────────────────────────
@pytest.fixture()
def projet_taches(client):
"""Un workspace + une base de tâches configurée (mapping complet)."""
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier MCP', 1)")
wid = cur.lastrowid
cur = conn.execute(
"INSERT INTO collections (name, description, icon, workspace_id, is_task) "
"VALUES ('Suivi projet', 'Base de tâches', '🗂️', ?, 1)", (wid,))
cid = cur.lastrowid
props = {}
for nom, typ, opts in (
("Assigné à", "person", "[]"),
("Statut", "status", json.dumps(
[{"name": "À faire"}, {"name": "Terminé"}])),
("Échéance", "date", "[]")):
cur = conn.execute(
"INSERT INTO collection_properties "
"(collection_id, name, prop_type, options_json) VALUES (?, ?, ?, ?)",
(cid, nom, typ, opts))
props[nom] = cur.lastrowid
conn.execute(
"UPDATE collections SET task_assignee_prop=?, task_status_prop=?, "
"task_due_prop=? WHERE id=?",
(props["Assigné à"], props["Statut"], props["Échéance"], cid))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, "
"property_values_json) VALUES (?, 'Relire le pont MCP', 'file', 1, ?)",
(cid, json.dumps({str(props["Statut"]): "À faire",
str(props["Échéance"]): "2026-10-30"})))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, "
"property_values_json) VALUES (?, 'Brancher ObsiGate', 'file', 2, ?)",
(cid, json.dumps({str(props["Statut"]): "Terminé"})))
conn.commit()
return {"workspace_id": wid, "collection_id": cid, "props": props}
def test_mcp_projets_catalogue(client, jeton, donnees, projet_taches):
projets = _appel(client, jeton, 90, "list_projects")
alpha = [p for p in projets if p["title"] == "Projet Alpha"]
assert alpha and alpha[0]["id"] == donnees
assert alpha[0]["url"].endswith(f"/collections/{donnees}")
assert alpha[0]["page_count"] == 2
suivi = [p for p in projets if p["title"] == "Suivi projet"]
assert suivi and suivi[0]["is_task"] is True
detail = _appel(client, jeton, 91, "get_project", {"id": donnees})
assert detail["project"]["title"] == "Projet Alpha"
assert detail["project"]["url"].endswith(f"/collections/{donnees}")
# Détail sans contenu complet : titres seulement.
assert [p["title"] for p in detail["pages"]] == ["Page de cadrage",
"Revue hebdo"]
assert all(set(p) == {"id", "title"} for p in detail["pages"])
res = _appel(client, jeton, 92, "get_project", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_list_project_tasks(client, jeton, projet_taches):
cid = projet_taches["collection_id"]
taches = _appel(client, jeton, 93, "list_project_tasks",
{"project_id": cid})
assert [t["title"] for t in taches] == ["Relire le pont MCP",
"Brancher ObsiGate"]
assert taches[0]["status"] == "À faire" and taches[0]["done"] is False
assert taches[0]["due"] == "2026-10-30"
assert taches[1]["done"] is True
assert taches[0]["url"].endswith(f"/collections/{cid}")
toutes = _appel(client, jeton, 94, "list_project_tasks")
assert {t["title"] for t in toutes} >= {"Relire le pont MCP"}
res = _appel(client, jeton, 95, "list_project_tasks",
{"project_id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_read_page_classique_et_troncature(client, jeton, projet_taches):
long_contenu = "mot " * 6000 # 24 000 caractères > borne 20 000
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, "
"parent_section) VALUES ('Atelier MCP', ?, 'Grande note', ?, 'Private')",
(projet_taches["workspace_id"], long_contenu))
pid = cur.lastrowid
conn.commit()
res = _appel(client, jeton, 96, "read_page", {"id": pid})
assert res["kind"] == "page" and res["title"] == "Grande note"
assert res["truncated"] is True and len(res["content"]) <= 20000
assert res["url"].endswith(f"/pages/{pid}")
res = _appel(client, jeton, 97, "read_page", {"id": 99999, "kind": "page"})
assert "introuvable" in res["erreur"]
def test_mcp_read_page_ligne_de_collection(client, jeton, donnees):
with get_conn() as conn:
ligne = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? AND "
"title='Page de cadrage'", (donnees,)).fetchone()["id"]
conn.execute(
"INSERT INTO pages (workspace, title, content, collection_row_id) "
"VALUES ('', 'contenu', 'Le contenu porté de la ligne', ?)",
(ligne,))
conn.commit()
res = _appel(client, jeton, 98, "read_page",
{"id": ligne, "kind": "collection_page"})
assert res["kind"] == "collection_page"
assert res["content"] == "Le contenu porté de la ligne"
assert res["truncated"] is False
assert res["properties"] == {"statut": "en cours"}
def test_mcp_jeton_lecture_seule_refuse_les_ecritures(client, jeton, donnees):
"""§7.4 : un jeton au scope « read » (pont de lecture) ne peut pas
écrire via MCP, mais il peut toujours lire."""
lecture = "fd_token_lecture_seule"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, "
"revoked, scopes) VALUES (1, 'pont-lecture', ?, ?, 0, 'read')",
(hashlib.sha256(lecture.encode()).hexdigest(), lecture[:12]))
conn.commit()
cols = _appel(client, lecture, 100, "list_projects")
assert any(c["title"] == "Projet Alpha" for c in cols)
r = _rpc(client, lecture, 101, "tools/call",
{"name": "create_page",
"arguments": {"collection_id": donnees, "title": "Interdite"}})
assert r.status_code == 200
erreur = r.json()["error"]
assert erreur["code"] == -32002 and "lecture seule" in erreur["message"]
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) FROM collection_pages WHERE title='Interdite'"
).fetchone()[0]
assert n == 0
def test_mcp_journalisation_des_appels(client, jeton, donnees):
"""§7.4 : chaque tools/call laisse une trace dans api_audit_log."""
_appel(client, jeton, 110, "list_projects")
with get_conn() as conn:
rows = conn.execute(
"SELECT action, user_id, token_id, detail FROM api_audit_log "
"WHERE action='mcp:list_projects'").fetchall()
assert rows, "aucune ligne d'audit pour l'appel MCP"
assert rows[-1]["user_id"] == 1 and rows[-1]["token_id"] is not None
assert "statut=ok" in rows[-1]["detail"]
+57
View File
@@ -0,0 +1,57 @@
"""Fusion ACC-4.4 — panneau de l'agent commun intégré à Flowdeck.
Le panneau commun (composant partagé des quatre apps, contrat
d'injection `dracodev-commons`) est intégré depuis la 7.81.0
(panneau v3), complété par le mode console (7.82.x) et la version du
service affichée en mode sidebar (7.83.1, fusion #64). Ces tests
figent le contrat d'intégration côté Flowdeck :
- le gabarit de base charge les assets du panneau et injecte la
configuration (URL du service d'agents résolue par configuration,
jamais un nom public codé en dur dans le JS) ;
- le panneau parle au service d'agents (conversation **commune** :
même discussion perpétuelle que dans les autres apps) ;
- les automatisations locales de Flowdeck restent locales (C5) :
le panneau commun ne remplace pas le moteur d'agent local.
"""
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
def test_gabarit_charge_le_panneau_et_injecte_la_config():
base = (RACINE / "app/templates/base.html").read_text(encoding="utf-8")
assert "/static/css/draco-panneau.css" in base
assert "/static/js/draco-panneau.js" in base
assert "window.DRACO_PANNEAU_CONFIG" in base
assert "draco_agent_url()" in base
def test_panneau_parle_au_service_agent_conversation_commune():
js = (RACINE / "static/js/draco-panneau.js").read_text(encoding="utf-8")
# Même conversation continue que dans les autres apps : le
# panneau s'appuie sur l'API du service (contrat commons).
assert "/api/v1/me" in js
assert "/api/v1/chat" in js
assert "/api/v1/conversations" in js
# La configuration vient de l'hôte ; le seul repli est le nom
# officiel — aucun nom public d'app en dur dans le composant.
assert "DRACO_PANNEAU_CONFIG" in js
for nom in ("flowdeck-dev", "og-dev", "tiktak-dev", "kanbante-dev",
"agent-dev"):
assert f"://{nom}." not in js # aucune URL publique -dev codée en dur
def test_assets_panneau_servis(client):
for chemin in ("/static/js/draco-panneau.js", "/static/css/draco-panneau.css"):
r = client.get(chemin)
assert r.status_code == 200, chemin
def test_automatisations_locales_conservées(client):
# C5 : l'agent local de Flowdeck (automatisations, gouvernance)
# reste en place à côté du panneau commun.
r = client.get("/api/v2/agent-approvals", params={"status": "pending"})
assert r.status_code == 200
assert "approvals" in r.json()
@@ -0,0 +1,191 @@
"""Fusion ACC-6.2 — pilote d'écriture approuvée : archiver un résumé
de réunion Flowdeck dans ObsiGate via la file d'approbations unifiée.
Chaîne testée : demande déposée dans `agent_approvals` (outil
`archiver_reunion_obsigate`) → décision dans la file (route
gouvernance) → si approuvée, `propose_write_file` part vers ObsiGate
par le pont MCP (ACC-2.2) et la distribution est journalisée (canal
`obsigate`). Rejet = rien n'est envoyé. Sans connecteur ObsiGate,
l'exécution échoue proprement avec un message explicite.
La preuve réelle reste conditionnelle (chantier Meetings A/B +
clé ObsiGate dédiée de Bruno) ; ici le serveur MCP est simulé.
"""
import asyncio
import json
import pytest
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import mcp_client, meeting_archive, meetings
from app.services.importers import url_fetch
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
monkeypatch.setattr(mcp_client, "_sessions", {})
@pytest.fixture
def admin_cookies():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin)"
" VALUES (1, 'tester', 'Tester', 1)")
conn.commit()
session = SessionManager.create_session(
{"id": 1, "login": "tester", "is_admin": 1})
return {"flowdeck_session": session}
def _transcript_avec_resume():
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Réunion hebdo', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id, summary)"
" VALUES (?, ?)",
(pid, "## Décisions\n\n- Lancer le pilote d'archivage.\n"
"- Revoir le pont MCP la semaine prochaine.")).lastrowid
conn.commit()
meetings._update(tid, generated_title="Hebdo produit", status="done")
return tid
def _creer_connecteur_obsigate(client):
r = client.post("/api/agent/connectors", json={
"name": "ObsiGate", "kind": "obsigate",
"url": "https://og.dracodev.net/mcp",
"secret": "og_cle_pont_test", "auth": "bearer"})
assert r.status_code == 200, r.text
return r.json()["id"]
def _faux_rpc_obsigate(appels):
async def fake_rpc(url, payload, headers=None):
appels.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2025-03-26",
"serverInfo": {"name": "obsigate"}}}
if method == "tools/call":
params = payload.get("params") or {}
appels.append((params.get("name"), params.get("arguments")))
return {"result": {"content": [
{"type": "text", "text": json.dumps({"draft_id": "b-42"})}]}}
return {}
return fake_rpc
def test_demande_approbation_et_gardes(client, admin_cookies):
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 201, r.text
body = r.json()
assert body["titre"] == "Hebdo produit"
assert body["chemin"].startswith("Réunions/")
assert body["chemin"].endswith("Hebdo produit.md")
approval = body["approval"]
assert approval["tool"] == "archiver_reunion_obsigate"
assert approval["status"] == "pending"
assert json.loads(approval["args_json"])["transcript_id"] == tid
# Doublon refusé tant que la demande est en attente.
r2 = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r2.status_code == 400
def test_resume_vide_refuse(client, admin_cookies):
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Vide', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id) VALUES (?)",
(pid,)).lastrowid
conn.commit()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 400
assert "Résumé vide" in r.text
def test_approbation_execute_la_proposition(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
aid = r.json()["approval"]["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
assert d.status_code == 200, d.text
body = d.json()
assert body["status"] == "approved"
assert body["archive"]["status"] == "ok", body["archive"]
assert "Réunions/" in body["archive"]["detail"]
# propose_write_file a reçu le résumé, jamais un apply direct.
calls = [a for a in appels if isinstance(a, tuple)]
assert len(calls) == 1 and calls[0][0] == "propose_write_file"
assert "Lancer le pilote" in calls[0][1]["content"]
assert calls[0][1]["path"].endswith("Hebdo produit.md")
# Distribution journalisée, canal obsigate.
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM meeting_distributions WHERE transcript_id=?",
(tid,)).fetchall()
assert len(rows) == 1
assert rows[0]["channel"] == "obsigate" and rows[0]["status"] == "propose"
def test_rejet_ne_envoie_rien(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": False})
assert d.json()["archive"]["status"] == "rejete"
assert not [a for a in appels if isinstance(a, tuple)]
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) AS n FROM meeting_distributions"
" WHERE transcript_id=?", (tid,)).fetchone()["n"]
assert n == 0
def test_sans_connecteur_erreur_explicite(client, admin_cookies):
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
archive = d.json()["archive"]
assert archive["status"] == "erreur"
assert "Connecteur ObsiGate non configuré" in archive["detail"]
def test_construire_archive_contenu(client):
tr = meetings.get_transcript(_transcript_avec_resume())
archive = meeting_archive.construire_archive(tr)
assert archive["chemin"].startswith("Réunions/")
assert "# Hebdo produit" in archive["contenu"]
assert "Lancer le pilote" in archive["contenu"]
assert "/pages/" in archive["contenu"] # lien profond vers la note
def test_executer_async_direct(client, monkeypatch):
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
approval = meeting_archive.demander_approbation(tid, 1)
# Sans connecteur : erreur propre, pas d'exception.
out = asyncio.run(meeting_archive.executer(approval))
assert out["status"] == "erreur"
+320
View File
@@ -0,0 +1,320 @@
"""Fusion ACC-2.2 — pont MCP Flowdeck → ObsiGate (plan « étape 1 :
lectures seules », tests T0–T5 du plan ``pont-mcp-etape1``).
Ce que le code livre ici (la preuve réelle T0–T5 contre l'ObsiGate de
Bruno exige sa clé dédiée limitée à un vault + ses arbitrages Q-A/Q-B) :
- **T0 (connectivité)** : le client MCP parle à un serveur **à session**
façon ObsiGate — ``initialize`` renvoie ``Mcp-Session-Id``, tout
appel sans cet en-tête est refusé ; le handshake précède aussi
``tools/call``. Réponses SSE multi-messages décodées correctement.
- **Preset « obsigate »** : URL ``<base ObsiGate>/mcp`` dérivée de la
configuration (dev/prod), clé chiffrée jamais renvoyée.
- **Garde-fou n° 2** : les outils MCP sont **classés** à la découverte
(annotations ``readOnlyHint``, sinon heuristique de nom) — les
écrivants (``propose_*``…) passent par les portes existantes
(politique d'approbation + rôles) ; ``apply_*`` est **bloqué** côté
client (patron §7.5 : l'écriture croisée s'approuve dans l'app
propriétaire).
"""
import asyncio
import json
import httpx
import pytest
from app.db import get_conn
from app.services import connectors, http_client, mcp_client
from app.services.importers import url_fetch
from app.services.tool_registry import ToolRegistry
SESSION_ID = "sess-og-test-123"
URL_PONT = "https://og.dracodev.net/mcp"
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
"""Même neutralisation DNS que test_fusion_tiktak (hôtes publics
résolus en 198.18.0.0/15 localement, refusés par la garde SSRF)."""
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
monkeypatch.setattr(mcp_client, "_sessions", {})
# Outils tels qu'ObsiGate les déclare sur son serveur MCP : lectures,
# puis le patron d'écriture propose/apply (spec d'accès centralisé §7.5).
OUTILS_OBSIGATE = [
{"name": "list_vaults",
"description": "Liste les vaults accessibles au jeton.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "search_fulltext",
"description": "Recherche plein texte dans le vault du jeton.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]},
"annotations": {"readOnlyHint": True}},
{"name": "read_file",
"description": "Lit un fichier du vault.",
"inputSchema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
{"name": "propose_write_file",
"description": "Propose une écriture (brouillon à approuver).",
"inputSchema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]},
"annotations": {"readOnlyHint": False}},
{"name": "apply_write_file",
"description": "Applique une écriture approuvée.",
"inputSchema": {"type": "object",
"properties": {"draft_id": {"type": "string"}},
"required": ["draft_id"]}},
]
def _gestionnaire_obsigate(appels):
"""Rejoue le serveur MCP d'ObsiGate : Streamable HTTP **à session
stricte** (tout appel post-initialize sans ``Mcp-Session-Id``
valide → erreur -32000), ``tools/list`` répondu en SSE
multi-messages (une notification précède la réponse)."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content or b"{}")
method = body.get("method")
appels.append((method, dict(request.headers)))
if method == "initialize":
return httpx.Response(
200,
json={"jsonrpc": "2.0", "id": body.get("id"),
"result": {"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"serverInfo": {"name": "obsigate",
"version": "2.78"}}},
headers={"Mcp-Session-Id": SESSION_ID})
if request.headers.get("mcp-session-id") != SESSION_ID:
return httpx.Response(
400,
json={"jsonrpc": "2.0", "id": body.get("id"),
"error": {"code": -32000,
"message": "Mcp-Session-Id manquant ou invalide"}})
if method == "notifications/initialized":
return httpx.Response(202, content=b"")
if method == "tools/list":
sse = (
'event: message\n'
'data: {"jsonrpc": "2.0", "method": "notifications/progress",'
' "params": {"message": "scan des vaults"}}\n\n'
'event: message\n'
f'data: {json.dumps({"jsonrpc": "2.0", "id": body.get("id"), "result": {"tools": OUTILS_OBSIGATE}})}\n\n'
)
return httpx.Response(200, content=sse.encode(),
headers={"Content-Type": "text/event-stream"})
if method == "tools/call":
nom = (body.get("params") or {}).get("name")
if nom == "search_fulltext":
res = [{"path": "Notes/Pont MCP.md", "vault": "home-bruno",
"extraits": ["le pont MCP relie Flowdeck à ObsiGate"]}]
elif nom == "read_file":
res = {"path": "Notes/Pont MCP.md",
"content": "Contenu du fichier Pont MCP."}
elif nom == "list_vaults":
res = [{"name": "home-bruno"}]
else:
res = {"ok": True}
return httpx.Response(
200, json={"jsonrpc": "2.0", "id": body.get("id"),
"result": {"content": [
{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}})
return httpx.Response(200, json={"jsonrpc": "2.0",
"id": body.get("id"), "result": {}})
return handler
@pytest.fixture
def faux_obsigate(monkeypatch):
"""Branche le vrai ``_rpc`` du client sur le faux serveur (transport
httpx simulé) : sessions, en-têtes et SSE sont exercés pour de vrai."""
appels: list = []
transport = httpx.MockTransport(_gestionnaire_obsigate(appels))
vraie_classe = http_client.shared_client
class FauxShared:
def __init__(self, **kwargs):
kwargs["transport"] = transport
self._inner = vraie_classe(**kwargs)
async def __aenter__(self):
return await self._inner.__aenter__()
async def __aexit__(self, *exc):
return await self._inner.__aexit__(*exc)
monkeypatch.setattr(http_client, "shared_client", FauxShared)
return appels
def _creer_obsigate(client, **kw):
body = {"name": "ObsiGate", "kind": "obsigate", "url": URL_PONT,
"secret": "og_cle_pont_test", "auth": "bearer", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
def _sonder(client):
row = _creer_obsigate(client)
res = asyncio.run(connectors.probe(str(row["id"])))
assert res["status"] == "ok", res
return row, res
# ── Preset ───────────────────────────────────────────────────────────────
def test_preset_obsigate_et_secret_chiffre(client):
row = _creer_obsigate(client)
assert row["kind"] == "obsigate" and row["auth"] == "bearer"
assert row["has_secret"] is True
assert "og_cle_pont_test" not in json.dumps(row) # jamais renvoyée
# URL par défaut = défaut officiel ; en dev, draco_urls la dérive
# en og-dev (résolution par configuration, chantier dev/prod).
assert connectors.PRESETS["obsigate"]["url"] == "https://og.dracodev.net/mcp"
hdrs = connectors._headers(row["id"])
assert hdrs["Authorization"] == "Bearer og_cle_pont_test"
# ── T0 : session + handshake + SSE ───────────────────────────────────────
def test_t0_session_handshake_et_cache(client, faux_obsigate):
row, res = _sonder(client)
assert "5 outil(s)" in res["detail"]
methodes = [m for m, _ in faux_obsigate]
assert methodes[:3] == ["initialize", "notifications/initialized", "tools/list"]
# L'appel tools/list porte la session émise à initialize — sans
# elle, le faux serveur refuse (session stricte) et le probe échoue.
entetes_list = [h for m, h in faux_obsigate if m == "tools/list"][0]
assert entetes_list.get("mcp-session-id") == SESSION_ID
assert entetes_list.get("authorization") == "Bearer og_cle_pont_test"
with get_conn() as conn:
brut = conn.execute(
"SELECT tools_json FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["tools_json"]
outils = {t["original"]: t for t in json.loads(brut)}
assert set(outils) == {"list_vaults", "search_fulltext", "read_file",
"propose_write_file", "apply_write_file"}
# Classification à la découverte (garde-fou n° 2).
assert outils["search_fulltext"]["write"] is False # annotation readOnlyHint
assert outils["read_file"]["write"] is False # défaut = lecture
assert outils["propose_write_file"]["write"] is True # annotation + préfixe
assert outils["apply_write_file"]["write"] is True # préfixe apply_
def test_t1_appel_outil_lecture_avec_session(client, faux_obsigate):
_sonder(client)
reg = ToolRegistry()
noms = {t["name"] for t in reg.schema()}
assert "mcp_obsigate_search_fulltext" in noms
assert "mcp_obsigate_read_file" in noms
avant = len(faux_obsigate)
res = asyncio.run(reg.execute("mcp_obsigate_search_fulltext",
{"query": "pont MCP"}))
assert res.status == "success", res
assert "Pont MCP.md" in res.data["text"]
# call_tool refait le handshake complet, session réémise partout.
sequence = [m for m, _ in faux_obsigate[avant:]]
assert sequence == ["initialize", "notifications/initialized", "tools/call"]
entetes_call = [h for m, h in faux_obsigate if m == "tools/call"][0]
assert entetes_call.get("mcp-session-id") == SESSION_ID
res2 = asyncio.run(reg.execute("mcp_obsigate_read_file",
{"path": "Notes/Pont MCP.md"}))
assert res2.status == "success"
assert "Contenu du fichier Pont MCP." in res2.data["text"]
def test_parse_body_sse_multi_messages():
sse = (
': keep-alive\n'
'event: message\n'
'data: {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}\n\n'
'event: message\n'
'data: {"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}\n\n'
)
# Sans id attendu : le message porteur de result gagne sur la notification.
assert mcp_client.parse_body("text/event-stream", sse, 200) == {
"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}
# Avec id attendu : c'est lui qui tranche.
assert mcp_client.parse_body("text/event-stream", sse, 200,
expected_id=2)["result"] == {"tools": []}
def test_classer_outil():
assert mcp_client.classer_outil("search_fulltext") is False
assert mcp_client.classer_outil("read_file", {"readOnlyHint": True}) is False
assert mcp_client.classer_outil("propose_write_file") is True
assert mcp_client.classer_outil("apply_write_file") is True
assert mcp_client.classer_outil("create_note") is True
# L'annotation du serveur fait foi, même contre l'heuristique.
assert mcp_client.classer_outil("create_note", {"readOnlyHint": True}) is False
assert mcp_client.classer_outil("get_stats", {"readOnlyHint": False}) is True
# ── Garde-fous : portes de gouvernance + blocage apply_* ────────────────
def test_apply_bloque_cote_client(client, faux_obsigate):
_sonder(client)
reg = ToolRegistry()
res = asyncio.run(reg.execute("mcp_obsigate_apply_write_file",
{"draft_id": "b-1"}))
assert res.status == "error"
assert "bloqué" in res.message
# Aucun tools/call n'a quitté le client pour cet outil.
assert [m for m, _ in faux_obsigate].count("tools/call") == 0
def test_portes_gouvernance_outils_mcp(client, faux_obsigate):
from app.services.agent_policies import check_tool
from app.services.permission_manager import PermissionManager, is_write_tool
row, _res = _sonder(client)
assert row["id"]
assert is_write_tool("mcp_obsigate_propose_write_file") is True
assert is_write_tool("mcp_obsigate_apply_write_file") is True
assert is_write_tool("mcp_obsigate_search_fulltext") is False
assert is_write_tool("create_page") is True # interne, inchangé
assert is_write_tool("search") is False
# Politique globale « approbation requise » : l'écrivant MCP est
# intercepté comme un écrivant interne, la lecture passe.
with get_conn() as conn:
conn.execute(
"INSERT INTO agent_policies (workspace_id, require_approval)"
" VALUES (NULL, 1)")
conn.commit()
gate = check_tool(1, None, "mcp_obsigate_propose_write_file",
is_write=is_write_tool("mcp_obsigate_propose_write_file"))
assert gate["allowed"] is False and gate["approval_id"]
ok = check_tool(1, None, "mcp_obsigate_search_fulltext",
is_write=is_write_tool("mcp_obsigate_search_fulltext"))
assert ok["allowed"] is True
# Rôles : un viewer ne peut pas lancer un outil MCP écrivant.
from fastapi import HTTPException
with get_conn() as conn:
admin = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
conn.execute("INSERT INTO users (login, is_admin) VALUES ('lecteur_pont', 0)")
lecteur = conn.execute(
"SELECT id FROM users WHERE login='lecteur_pont'").fetchone()
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws-pont', ?)",
(admin["id"],))
ws = conn.execute("SELECT id FROM workspaces WHERE name='ws-pont'").fetchone()
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role)"
" VALUES (?,?, 'viewer')", (ws["id"], lecteur["id"]))
conn.commit()
pm = PermissionManager(lecteur["id"])
with pytest.raises(HTTPException):
pm.assert_can("mcp_obsigate_propose_write_file", {}, ws["id"])
pm.assert_can("mcp_obsigate_search_fulltext", {}, ws["id"])