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:
+138
-26
@@ -6,10 +6,30 @@ JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list
|
||||
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
|
||||
sans état en mémoire.
|
||||
|
||||
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
|
||||
réponse JSON directe, soit la première ligne ``data:`` d'une
|
||||
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
|
||||
répondent en JSON chez la grande majorité des serveurs).
|
||||
**Sessions (ACC-2.2, pont ObsiGate — test T0 du plan d'étape 1).**
|
||||
Certains serveurs Streamable HTTP (ObsiGate) sont **à session** : ils
|
||||
renvoient un en-tête ``Mcp-Session-Id`` à ``initialize`` et refusent
|
||||
tout appel qui ne le réémet pas. Le client capture donc cet en-tête à
|
||||
chaque réponse et le réémet sur les appels suivants de la même
|
||||
opération ; ``call_tool`` fait désormais le handshake complet avant
|
||||
``tools/call`` (les serveurs sans état comme TikTak l'acceptent sans
|
||||
frais). Réponses **JSON ou SSE** : dans un flux ``text/event-stream``
|
||||
qui porte plusieurs messages (notifications + réponse), c'est le
|
||||
message **de réponse** (id correspondant, sinon porteur de
|
||||
``result``/``error``) qui est retenu — pas le premier ``data:`` venu.
|
||||
|
||||
**Classification des outils (garde-fou n° 2 du plan du pont).** Chaque
|
||||
outil découvert est classé au moment du cache : **lecture par défaut** ;
|
||||
écrivant si le serveur le déclare (annotation ``readOnlyHint: false``)
|
||||
ou si son nom porte un verbe d'écriture (``create_``, ``propose_``,
|
||||
``apply_``…). La classe est consommée par les portes de gouvernance
|
||||
(``agent_policies.check_tool`` et ``PermissionManager`` via
|
||||
``permission_manager.is_write_tool``) : un outil MCP écrivant suit le
|
||||
même régime d'approbation et de rôles que les outils internes.
|
||||
|
||||
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; pas de
|
||||
client SSE à l'état (les méthodes utilisées répondent en JSON ou en
|
||||
événements ponctuels chez les serveurs du programme).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +39,7 @@ import logging
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.connectors import _headers, _with_secret, get
|
||||
from app.services.connectors import MCP_KINDS, _headers, _with_secret, get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +47,36 @@ logger = logging.getLogger(__name__)
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
|
||||
|
||||
#: Verbes d'écriture reconnus en tête d'un nom d'outil MCP quand le
|
||||
#: serveur ne déclare pas d'annotation ``readOnlyHint``. ``propose_`` /
|
||||
#: ``apply_`` sont le patron d'écriture approuvée d'ObsiGate : les deux
|
||||
#: sont classés écrivants (voir aussi le blocage d'``apply_`` dans
|
||||
#: ``tool_registry.McpTool``).
|
||||
_PREFIXES_ECRITURE = (
|
||||
"create_", "update_", "delete_", "add_", "remove_", "set_", "move_",
|
||||
"write_", "edit_", "rename_", "propose_", "apply_", "archive_",
|
||||
"unarchive_", "send_", "publish_", "close_", "sync_", "trigger_",
|
||||
"import_",
|
||||
)
|
||||
|
||||
#: Derniers ``Mcp-Session-Id`` capturés, par URL de serveur. Mémoire de
|
||||
#: processus volontairement éphémère : une session perdue se rouvre par
|
||||
#: un handshake, jamais par une persistance.
|
||||
_sessions: dict[str, str] = {}
|
||||
|
||||
|
||||
def classer_outil(nom_original: str, annotations: dict | None = None) -> bool:
|
||||
"""True si l'outil MCP est **écrivant**.
|
||||
|
||||
L'annotation du serveur fait foi quand elle existe
|
||||
(``readOnlyHint``) ; sinon, heuristique de nom. Lecture par défaut —
|
||||
c'est la règle du plan du pont : un outil ne devient écrivant que
|
||||
s'il est déclaré comme tel.
|
||||
"""
|
||||
if isinstance(annotations, dict) and "readOnlyHint" in annotations:
|
||||
return not bool(annotations["readOnlyHint"])
|
||||
return (nom_original or "").lower().startswith(_PREFIXES_ECRITURE)
|
||||
|
||||
|
||||
def tool_name(server_name: str, tool_name: str) -> str:
|
||||
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
|
||||
@@ -36,24 +86,66 @@ def tool_name(server_name: str, tool_name: str) -> str:
|
||||
return re.sub(r"_{2,}", "_", joined)
|
||||
|
||||
|
||||
def parse_body(content_type: str, body: str, status: int) -> dict:
|
||||
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
|
||||
def parse_body(content_type: str, body: str, status: int,
|
||||
expected_id=None) -> dict:
|
||||
"""Corps MCP → dict JSON (JSON direct ou événements ``data:`` SSE).
|
||||
|
||||
Dans un flux SSE multi-messages, le message retenu est celui dont
|
||||
l'``id`` correspond à la requête (``expected_id``), sinon le
|
||||
premier porteur de ``result``/``error``, sinon le premier message.
|
||||
"""
|
||||
text = body or ""
|
||||
data = None
|
||||
if "text/event-stream" in (content_type or ""):
|
||||
candidats: list = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
text = line[5:].strip()
|
||||
break
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
|
||||
try:
|
||||
candidats.append(json.loads(line[5:].strip()))
|
||||
except ValueError:
|
||||
continue
|
||||
if expected_id is not None:
|
||||
data = next((c for c in candidats
|
||||
if isinstance(c, dict) and c.get("id") == expected_id),
|
||||
None)
|
||||
if data is None:
|
||||
data = next((c for c in candidats
|
||||
if isinstance(c, dict)
|
||||
and ("result" in c or "error" in c)), None)
|
||||
if data is None and candidats:
|
||||
data = candidats[0]
|
||||
if data is None:
|
||||
raise ValueError(f"Réponse MCP illisible (HTTP {status})")
|
||||
else:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
err = data["error"] if isinstance(data["error"], dict) else {}
|
||||
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _capturer_session(url: str, headers) -> None:
|
||||
"""Mémorise le ``Mcp-Session-Id`` d'une réponse, s'il est présent."""
|
||||
try:
|
||||
sid = headers.get("mcp-session-id") if headers else None
|
||||
except AttributeError:
|
||||
sid = None
|
||||
if sid:
|
||||
_sessions[url] = sid
|
||||
|
||||
|
||||
def _session_headers(url: str, headers: dict | None) -> dict:
|
||||
"""En-têtes d'appel + session connue pour cette URL (si capturée)."""
|
||||
out = dict(headers or {})
|
||||
sid = _sessions.get(url)
|
||||
if sid and not any(k.lower() == "mcp-session-id" for k in out):
|
||||
out["Mcp-Session-Id"] = sid
|
||||
return out
|
||||
|
||||
|
||||
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
|
||||
from app.services.http_client import shared_client
|
||||
@@ -65,8 +157,9 @@ async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||
**(headers or {})}
|
||||
async with shared_client(timeout=15, headers=hdrs) as client:
|
||||
resp = await client.post(safe, json=payload)
|
||||
_capturer_session(url, resp.headers)
|
||||
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
|
||||
resp.status_code)
|
||||
resp.status_code, expected_id=payload.get("id"))
|
||||
|
||||
|
||||
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
|
||||
@@ -77,11 +170,30 @@ async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
|
||||
logger.debug("MCP notification ignorée: %s", exc)
|
||||
|
||||
|
||||
async def _handshake(url: str, headers: dict | None) -> dict:
|
||||
"""``initialize`` + ``notifications/initialized`` → en-têtes de session.
|
||||
|
||||
Obligatoire avant tout ``tools/call`` contre un serveur à session
|
||||
(ObsiGate) ; sans effet de bord contre un serveur sans état. Les
|
||||
en-têtes retournés portent le ``Mcp-Session-Id`` capturé pendant
|
||||
``initialize`` quand le serveur en a émis un.
|
||||
"""
|
||||
await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
||||
"clientInfo": CLIENT_INFO},
|
||||
}, headers)
|
||||
session = _session_headers(url, headers)
|
||||
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
session)
|
||||
return session
|
||||
|
||||
|
||||
def _server(connector_id: int) -> dict:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
raise ValueError("Serveur MCP introuvable")
|
||||
if row["kind"] not in ("mcp", "tiktak"):
|
||||
if row["kind"] not in MCP_KINDS:
|
||||
raise ValueError("Ce connecteur n'est pas un serveur MCP")
|
||||
if not row["enabled"]:
|
||||
raise ValueError("Serveur MCP désactivé")
|
||||
@@ -102,6 +214,7 @@ def _normalize(server: dict, raw: list) -> list[dict]:
|
||||
"original": str(t["name"]),
|
||||
"description": str(t.get("description") or "Outil MCP"),
|
||||
"parameters": params,
|
||||
"write": classer_outil(str(t["name"]), t.get("annotations")),
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -117,15 +230,9 @@ async def initialize_and_list(connector_id: int) -> list[dict]:
|
||||
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
||||
"clientInfo": CLIENT_INFO},
|
||||
}, srv["headers"])
|
||||
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
srv["headers"])
|
||||
session = await _handshake(url, srv["headers"])
|
||||
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": {}}, srv["headers"])
|
||||
"params": {}}, session)
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
tools = _normalize(srv, result.get("tools") or [])
|
||||
_save_tools(connector_id, tools)
|
||||
@@ -137,7 +244,7 @@ def cached_tools() -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, tools_json FROM agent_connectors "
|
||||
"WHERE kind IN ('mcp', 'tiktak') AND enabled=1"
|
||||
"WHERE kind IN ('mcp', 'tiktak', 'obsigate') AND enabled=1"
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
@@ -170,13 +277,18 @@ def _content_text(result: dict) -> str:
|
||||
|
||||
|
||||
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
|
||||
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
|
||||
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
|
||||
|
||||
Le handshake (avec session éventuelle) précède systématiquement
|
||||
l'appel : c'est la condition T0 des serveurs à session.
|
||||
"""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
session = await _handshake(url, srv["headers"])
|
||||
data = await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": tool, "arguments": arguments or {}},
|
||||
}, srv["headers"])
|
||||
}, session)
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
text = _content_text(result)[:20000]
|
||||
return {"status": "error" if result.get("isError") else "ok", "text": text}
|
||||
|
||||
Reference in New Issue
Block a user