Files
flowdeck/tests/test_fusion_mcp_server.py
T
Spark 19b27698f2
FlowDeck CI / test (push) Failing after 3h11m23s
FlowDeck CI / lint (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
feat(fusion): serveur MCP complété (ACC-5.1) + pont MCP ObsiGate (ACC-2.2) — 7.83.0
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

404 lines
18 KiB
Python

"""Fusion — serveur MCP Flowdeck (branchement du service d'agents).
POST /mcp, authentifié par les jetons d'API personnels existants
(api_tokens). Le service d'agents lit et écrit (création/mise à
jour de pages, création de collections — aucune suppression) au
nom du propriétaire du jeton.
"""
import hashlib
import json
import pytest
from app.db import get_conn
@pytest.fixture()
def jeton(client):
brut = "fd_test_token_pour_mcp"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (1, 'service-agents', ?, ?, 0)",
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
conn.commit()
return brut
@pytest.fixture()
def donnees(client):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description, icon) VALUES ('Projet Alpha', 'Le projet de test', '🚀')")
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
"VALUES (?, 'Page de cadrage', 'file', 1, ?)",
(cid, json.dumps({"statut": "en cours"})))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
"VALUES (?, 'Revue hebdo', 'file', 2, '{}')", (cid,))
conn.commit()
return cid
def _rpc(client, jeton, rid, methode, params=None):
h = {"Authorization": f"Bearer {jeton}"} if jeton else {}
corps = {"jsonrpc": "2.0", "id": rid, "method": methode}
if params is not None:
corps["params"] = params
return client.post("/mcp", json=corps, headers=h)
def _appel(client, jeton, rid, nom, args=None):
r = _rpc(client, jeton, rid, "tools/call", {"name": nom, "arguments": args or {}})
assert r.status_code == 200, r.text
return json.loads(r.json()["result"]["content"][0]["text"])
def test_mcp_sans_jeton_401(client):
r = _rpc(client, None, 1, "tools/list")
assert r.status_code == 401
r = _rpc(client, "mauvais-jeton", 1, "tools/list")
assert r.status_code == 401
def test_mcp_handshake_et_outils(client, jeton):
r = _rpc(client, jeton, 1, "initialize", {})
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
r = _rpc(client, jeton, 2, "tools/list")
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", "list_projects",
"get_project", "read_page", "list_project_tasks",
"create_page", "update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
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 ('Techno', 1)")
wid = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section) "
"VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,))
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) "
"VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,))
conn.commit()
ws = _appel(client, jeton, 70, "list_workspaces")
techno = [w for w in ws if w["name"] == "Techno"]
assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas
detail = _appel(client, jeton, 71, "get_workspace", {"id": wid})
assert detail["workspace"]["name"] == "Techno"
assert [p["title"] for p in detail["pages"]] == ["Page racine"]
res = _appel(client, jeton, 72, "get_workspace", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_collections_et_pages(client, jeton, donnees):
cols = _appel(client, jeton, 1, "list_collections")
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
assert alpha and alpha[0]["page_count"] == 2
detail = _appel(client, jeton, 2, "get_collection", {"id": donnees})
assert detail["collection"]["name"] == "Projet Alpha"
titres = [p["title"] for p in detail["pages"]]
assert titres == ["Page de cadrage", "Revue hebdo"]
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
res = _appel(client, jeton, 3, "search_pages", {"query": "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"]
def test_mcp_create_page_puis_relecture(client, jeton, donnees):
creee = _appel(client, jeton, 10, "create_page", {
"collection_id": donnees, "title": "Page créée via MCP",
"icon": "file", "properties": {"statut": "à faire", "priorite": "haute"}})
assert creee["title"] == "Page créée via MCP"
assert creee["collection_id"] == donnees
assert creee["properties"] == {"statut": "à faire", "priorite": "haute"}
detail = _appel(client, jeton, 11, "get_collection", {"id": donnees})
titres = [p["title"] for p in detail["pages"]]
assert "Page créée via MCP" in titres
relue = [p for p in detail["pages"] if p["id"] == creee["id"]][0]
assert relue["properties"] == {"statut": "à faire", "priorite": "haute"}
def test_mcp_update_page_titre_et_proprietes(client, jeton, donnees):
creee = _appel(client, jeton, 20, "create_page", {
"collection_id": donnees, "title": "Titre initial",
"properties": {"statut": "brouillon"}})
maj = _appel(client, jeton, 21, "update_page", {
"id": creee["id"], "title": "Titre mis à jour",
"properties": {"statut": "publié"}})
assert maj["title"] == "Titre mis à jour"
# properties remplace property_values_json (pas de fusion)
assert maj["properties"] == {"statut": "publié"}
relue = _appel(client, jeton, 22, "get_page", {"id": creee["id"]})
assert relue["title"] == "Titre mis à jour"
assert relue["properties"] == {"statut": "publié"}
def test_mcp_create_collection(client, jeton):
creee = _appel(client, jeton, 30, "create_collection", {
"name": "Projet Bêta", "description": "Créée via MCP", "icon": "🧪"})
assert creee["name"] == "Projet Bêta"
assert creee["description"] == "Créée via MCP"
assert creee["icon"] == "🧪"
cols = _appel(client, jeton, 31, "list_collections")
beta = [c for c in cols if c["name"] == "Projet Bêta"]
assert beta and beta[0]["id"] == creee["id"]
assert beta[0]["page_count"] == 0
detail = _appel(client, jeton, 32, "get_collection", {"id": creee["id"]})
assert detail["collection"]["name"] == "Projet Bêta"
assert detail["pages"] == []
def test_mcp_erreurs_ecritures(client, jeton, donnees):
res = _appel(client, jeton, 40, "create_page", {
"collection_id": 99999, "title": "Orpheline"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 41, "create_page", {
"collection_id": donnees, "title": " "})
assert "erreur" in res
res = _appel(client, jeton, 42, "update_page", {
"id": 99999, "title": "Fantôme"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 43, "create_collection", {"name": " "})
assert "erreur" in res
def test_mcp_ecriture_sans_jeton_401(client, donnees):
r = _rpc(client, None, 50, "tools/call",
{"name": "create_page",
"arguments": {"collection_id": donnees, "title": "Refusée"}})
assert r.status_code == 401
r = _rpc(client, "mauvais-jeton", 51, "tools/call",
{"name": "create_collection", "arguments": {"name": "Refusée"}})
assert r.status_code == 401
def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
"""Régression 7.76.1 : un vrai client MCP est machine-à-machine
(Bearer seul, ni session ni jeton CSRF) — exactement le service
d'agents. Le client de test habituel porte une session + un CSRF,
ce qui masquait le 403 du middleware en production."""
from tests.conftest import anon
anon(client)
r = _rpc(client, jeton, 60, "initialize", {})
assert r.status_code == 200, r.text
cols = _appel(client, jeton, 61, "list_collections")
assert any(c["name"] == "Projet Alpha" for c in cols)
r = _rpc(client, None, 62, "tools/list")
assert r.status_code == 401, r.text
def test_mcp_perimetre_du_jeton(client, jeton, donnees):
"""Régression 7.78.0 (cas réel de Bruno) : list_workspaces rendait
les 52 workspaces de la base, tous comptes confondus. Chaque jeton
ne voit que les workspaces de son propriétaire (ou ceux dont il
est membre) et les collections rattachées à ce périmètre."""
autre = "fd_token_autre_compte"
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (2, 'autre')")
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (2, 'autre', ?, ?, 0)",
(hashlib.sha256(autre.encode()).hexdigest(), autre[:12]))
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Espace de lautre', 2)")
wid_autre = cur.lastrowid
cur = conn.execute(
"INSERT INTO collections (name, description, icon, workspace_id) "
"VALUES ('Base de lautre', '', '📋', ?)", (wid_autre,))
base_autre = cur.lastrowid
conn.commit()
# Bruno (jeton principal) ne voit ni le workspace ni la base de l'autre.
noms = [w["name"] for w in _appel(client, jeton, 80, "list_workspaces")]
assert "Espace de lautre" not in noms
res = _appel(client, jeton, 81, "get_workspace", {"id": wid_autre})
assert "introuvable" in res["erreur"]
cols = [c["name"] for c in _appel(client, jeton, 82, "list_collections")]
assert "Base de lautre" not in cols and "Projet Alpha" in cols
res = _appel(client, jeton, 83, "get_collection", {"id": base_autre})
assert "introuvable" in res["erreur"]
# Et l'autre ne voit pas le workspace de Bruno, mais voit le sien.
noms2 = [w["name"] for w in _appel(client, autre, 84, "list_workspaces")]
assert noms2 == ["Espace de lautre"]
def test_mcp_info_get(client, jeton):
r = client.get("/mcp")
assert r.status_code == 200
infos = r.json()
assert infos["read_only"] is False
assert "create_page" in infos["tools"]
assert "update_page" in infos["tools"]
assert "create_collection" in infos["tools"]
# 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"]