diff --git a/backend/app/mcp_client.py b/backend/app/mcp_client.py new file mode 100644 index 0000000..5453476 --- /dev/null +++ b/backend/app/mcp_client.py @@ -0,0 +1,89 @@ +"""Client MCP : catalogue préconfiguré + sessions vers les serveurs activés. + +Un serveur désactivé n'est jamais démarré et n'expose aucun outil au modèle +(chaque outil injecté coûte du contexte). Connexion lazy au premier message, +session réutilisée ensuite. Toute panne est non bloquante pour le chat. +""" +from __future__ import annotations + +import logging + +from . import db + +logger = logging.getLogger(__name__) + +MCP_KEY = "mcp" + +# Catalogue embarqué. command=None => serveur "custom" (commande utilisateur). +CATALOG: dict[str, dict] = { + "playwright": { + "label": "Playwright (navigateur)", + "description": "Pilote un vrai navigateur : naviguer, cliquer, lire " + "la console, captures. L'agent teste réellement ses pages.", + "command": ["npx", "@playwright/mcp@latest", "--headless"], + "url_param": False, + "env_params": [], + # Limite le nombre d'outils injectés dans le prompt. + "expose": [ + "browser_navigate", "browser_click", "browser_type", + "browser_snapshot", "browser_console_messages", + "browser_take_screenshot", + ], + }, + "context7": { + "label": "Context7 (documentation)", + "description": "Documentation à jour de n'importe quelle librairie ou " + "framework (React, FastAPI, Tailwind…).", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "url_param": False, + "env_params": [], + "expose": None, + }, + "fetch": { + "label": "Fetch (lecture web)", + "description": "Lit proprement n'importe quelle URL (markdown épuré).", + "command": ["python", "-m", "mcp_server_fetch"], + "url_param": False, + "env_params": [], + "expose": None, + }, + "searxng": { + "label": "SearxNG (recherche web)", + "description": "Vraie recherche web via une instance SearxNG " + "(renseigner SEARXNG_URL).", + "command": ["npx", "-y", "mcp-searxng"], + "url_param": False, + "env_params": ["SEARXNG_URL"], + "expose": None, + }, + "custom": { + "label": "Personnalisé", + "description": "N'importe quel serveur MCP : colle une commande " + "(stdio) ou une URL (streamable HTTP).", + "command": None, + "url_param": True, + "env_params": [], + "expose": None, + }, +} + + +def get_mcp_state() -> dict: + """État activé/params de chaque serveur du catalogue (défaut : désactivé).""" + stored = db.get_config_value(MCP_KEY) or {} + return { + sid: { + "enabled": bool(stored.get(sid, {}).get("enabled", False)), + "params": dict(stored.get(sid, {}).get("params", {})), + } + for sid in CATALOG + } + + +def set_mcp_state(sid: str, *, enabled: bool, params: dict) -> dict: + if sid not in CATALOG: + raise KeyError(sid) + state = get_mcp_state() + state[sid] = {"enabled": enabled, "params": dict(params)} + db.set_config_value(MCP_KEY, state) + return state diff --git a/backend/requirements.txt b/backend/requirements.txt index 3638ce2..8be4c6d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,6 @@ httpx==0.28.1 pydantic==2.12.5 pydantic-settings==2.7.1 psutil==7.2.2 +mcp>=1.9 +pytest>=8.3 +pytest-asyncio>=0.25 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_mcp_config.py b/backend/tests/test_mcp_config.py new file mode 100644 index 0000000..2705dd4 --- /dev/null +++ b/backend/tests/test_mcp_config.py @@ -0,0 +1,39 @@ +import os +import tempfile + +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) +os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp()) + +from app import db # noqa: E402 +from app import mcp_client # noqa: E402 + +db.init_db() + + +def test_catalog_has_preconfigured_servers(): + for sid in ("playwright", "context7", "fetch", "searxng", "custom"): + assert sid in mcp_client.CATALOG + assert mcp_client.CATALOG[sid]["label"] + + +def test_state_defaults_disabled(): + state = mcp_client.get_mcp_state() + assert set(state) == set(mcp_client.CATALOG) + assert all(not s["enabled"] for s in state.values()) + + +def test_toggle_persists(): + mcp_client.set_mcp_state("fetch", enabled=True, params={}) + assert mcp_client.get_mcp_state()["fetch"]["enabled"] is True + mcp_client.set_mcp_state("fetch", enabled=False, params={}) + assert mcp_client.get_mcp_state()["fetch"]["enabled"] is False + + +def test_custom_params_persist(): + mcp_client.set_mcp_state( + "custom", enabled=False, params={"command": "npx -y some-mcp"} + ) + assert ( + mcp_client.get_mcp_state()["custom"]["params"]["command"] + == "npx -y some-mcp" + )