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.
This commit is contained in:
Spark
2026-10-26 12:38:04 -04:00
parent fd79a49d63
commit 19b27698f2
13 changed files with 1044 additions and 51 deletions
+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"]
+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"])