"""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 ``/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"])