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.
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user