"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0. JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list`` → ``tools/call``. Les outils sont **cachés** dans ``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par ``ToolRegistry`` sous des noms ``mcp__`` — outils dynamiques, sans état en mémoire. **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 import json import logging import re from app.db import get_conn from app.services.connectors import MCP_KINDS, _headers, _with_secret, get logger = logging.getLogger(__name__) # Version la plus répandue côté serveurs (un serveur plus récent rétrograde). 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__`` (slug minuscules/underscores).""" def slug(text: str) -> str: return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_") joined = f"mcp_{slug(server_name)}_{slug(tool_name)}" return re.sub(r"_{2,}", "_", joined) 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:"): 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 from app.services.importers.url_fetch import _validate_url safe = _validate_url(url) hdrs = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(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, expected_id=payload.get("id")) async def _notify(url: str, payload: dict, headers: dict | None = None) -> None: """Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées.""" try: await _rpc(url, payload, headers) except Exception as exc: # noqa: BLE001 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_KINDS: raise ValueError("Ce connecteur n'est pas un serveur MCP") if not row["enabled"]: raise ValueError("Serveur MCP désactivé") return {"id": row["id"], "name": row["name"], "url": row["url"], "headers": _headers(connector_id)} def _normalize(server: dict, raw: list) -> list[dict]: out = [] for t in raw if isinstance(raw, list) else []: if not isinstance(t, dict) or not t.get("name"): continue params = t.get("inputSchema") if not isinstance(params, dict): params = {"type": "object", "properties": {}} out.append({ "name": tool_name(server["name"], t["name"]), "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 def _save_tools(connector_id: int, tools: list[dict]) -> None: with get_conn() as conn: conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?", (json.dumps(tools), connector_id)) conn.commit() 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) session = await _handshake(url, srv["headers"]) data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "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) return tools def cached_tools() -> list[dict]: """Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry.""" with get_conn() as conn: rows = conn.execute( "SELECT id, tools_json FROM agent_connectors " "WHERE kind IN ('mcp', 'tiktak', 'obsigate') AND enabled=1" ).fetchall() out: list[dict] = [] for r in rows: try: tools = json.loads(r["tools_json"] or "[]") except (ValueError, TypeError): tools = [] for t in tools if isinstance(tools, list) else []: if isinstance(t, dict) and t.get("name"): out.append({**t, "connector_id": r["id"]}) return out def tools_text(connector_id: int) -> str: """Liste lisible des outils cachés (utilisée par ``connector_fetch``).""" tools = [t for t in cached_tools() if t["connector_id"] == connector_id] if not tools: return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)." return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools) def _content_text(result: dict) -> str: parts = [] for item in result.get("content") or []: if isinstance(item, dict) and item.get("type") == "text": parts.append(str(item.get("text") or "")) else: parts.append(json.dumps(item, ensure_ascii=False)[:2000]) return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000] async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict: """``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 {}}, }, 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}