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:
@@ -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