Add gitignor

This commit is contained in:
2026-04-07 22:06:36 +02:00
parent 49802d89d5
commit 5703cf5871
98 changed files with 5329 additions and 72 deletions

View File

@@ -12,12 +12,16 @@ Configure les serveurs dans un profil YAML sous la clé `mcp_servers` :
from __future__ import annotations
import asyncio
import os
import re
import threading
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Racine du projet (le dossier qui contient main.py)
_PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@dataclass
class MCPServerConfig:
@@ -27,6 +31,18 @@ class MCPServerConfig:
env: dict[str, str] | None = None
url: str | None = None
def resolved_args(self) -> list[str]:
"""Résout les chemins relatifs dans args par rapport à la racine du projet."""
result = []
for arg in self.args:
p = Path(arg)
if not p.is_absolute() and p.suffix in (".js", ".py", ".ts"):
resolved = (_PROJECT_ROOT / p).resolve()
result.append(str(resolved))
else:
result.append(arg)
return result
def _sanitize_name(name: str) -> str:
"""Transforme un nom en identifiant valide pour l'API Mistral (^[a-zA-Z0-9_-]{1,64}$)."""
@@ -34,7 +50,12 @@ def _sanitize_name(name: str) -> str:
class MCPManager:
"""Gère les connexions aux serveurs MCP et l'exécution des outils."""
"""Gère les connexions aux serveurs MCP et l'exécution des outils.
Chaque connexion tourne dans une "keeper task" qui possède toute la durée
de vie du context manager stdio_client / ClientSession. Cela évite l'erreur
anyio "Attempted to exit cancel scope in a different task".
"""
def __init__(self) -> None:
self._loop = asyncio.new_event_loop()
@@ -42,9 +63,9 @@ class MCPManager:
self._thread.start()
self._sessions: dict[str, Any] = {}
self._raw_tools: dict[str, list] = {}
# mistral_name -> (server_name, original_tool_name)
self._tool_map: dict[str, tuple[str, str]] = {}
self._exit_stacks: dict[str, AsyncExitStack] = {}
# stop event per server, signalled at shutdown
self._stop_events: dict[str, asyncio.Event] = {}
def _run_loop(self) -> None:
asyncio.set_event_loop(self._loop)
@@ -87,10 +108,18 @@ class MCPManager:
return self._run(self._call_tool_async(server_name, tool_name, arguments))
def shutdown(self) -> None:
"""Signale l'arrêt à toutes les keeper tasks et attend brièvement."""
async def _signal_all() -> None:
for ev in self._stop_events.values():
ev.set()
# Laisser une courte fenêtre pour que les tâches se terminent proprement
await asyncio.sleep(0.3)
try:
self._run(self._shutdown_async(), timeout=10)
self._run(_signal_all(), timeout=5)
except Exception:
pass
self._stop_events.clear()
def summary(self) -> list[tuple[str, int]]:
"""Retourne [(server_name, tool_count), ...] pour les serveurs connectés."""
@@ -112,38 +141,78 @@ class MCPManager:
print(f"[MCP] ❌ Connexion {cfg.name} impossible : {e}")
async def _connect_server(self, cfg: MCPServerConfig) -> None:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
"""Lance la keeper task et attend que la connexion soit établie."""
stop_event = asyncio.Event()
ready_event = asyncio.Event()
error_holder: list[Exception] = []
stack = AsyncExitStack()
self._stop_events[cfg.name] = stop_event
if cfg.command:
params = StdioServerParameters(
command=cfg.command,
args=cfg.args or [],
env=cfg.env,
)
read, write = await stack.enter_async_context(stdio_client(params))
else:
from mcp.client.sse import sse_client
read, write = await stack.enter_async_context(sse_client(cfg.url))
asyncio.create_task(
self._run_server(cfg, ready_event, stop_event, error_holder),
name=f"mcp-keeper-{cfg.name}",
)
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
# Attendre que la connexion soit prête (ou échoue)
try:
await asyncio.wait_for(ready_event.wait(), timeout=30)
except asyncio.TimeoutError:
stop_event.set()
raise TimeoutError(f"Timeout lors de la connexion à {cfg.name}")
self._sessions[cfg.name] = session
self._exit_stacks[cfg.name] = stack
if error_holder:
raise error_holder[0]
tools_resp = await session.list_tools()
self._raw_tools[cfg.name] = tools_resp.tools
async def _run_server(
self,
cfg: MCPServerConfig,
ready_event: asyncio.Event,
stop_event: asyncio.Event,
error_holder: list[Exception],
) -> None:
"""Keeper task : possède le context manager de bout en bout."""
try:
from mcp import ClientSession, StdioServerParameters
server_safe = _sanitize_name(cfg.name)
for tool in tools_resp.tools:
tool_safe = _sanitize_name(tool.name)
mistral_name = f"{server_safe}__{tool_safe}"
self._tool_map[mistral_name] = (cfg.name, tool.name)
if cfg.command:
from mcp.client.stdio import stdio_client
transport = stdio_client(StdioServerParameters(
command=cfg.command,
args=cfg.resolved_args(),
env=cfg.env,
))
else:
from mcp.client.sse import sse_client
transport = sse_client(cfg.url)
print(f"[MCP] ✅ {cfg.name}{len(tools_resp.tools)} outil(s) disponible(s)")
async with transport as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools_resp = await session.list_tools()
self._sessions[cfg.name] = session
self._raw_tools[cfg.name] = tools_resp.tools
server_safe = _sanitize_name(cfg.name)
for tool in tools_resp.tools:
tool_safe = _sanitize_name(tool.name)
self._tool_map[f"{server_safe}__{tool_safe}"] = (cfg.name, tool.name)
print(f"[MCP] ✅ {cfg.name}{len(tools_resp.tools)} outil(s) disponible(s)")
ready_event.set()
# Maintenir la connexion jusqu'au signal d'arrêt
await stop_event.wait()
except Exception as e:
error_holder.append(e)
ready_event.set() # débloquer _connect_server même en cas d'erreur
finally:
self._sessions.pop(cfg.name, None)
self._raw_tools.pop(cfg.name, None)
to_remove = [k for k, v in self._tool_map.items() if v[0] == cfg.name]
for k in to_remove:
del self._tool_map[k]
async def _call_tool_async(self, server_name: str, tool_name: str, arguments: dict) -> str:
session = self._sessions[server_name]
@@ -156,17 +225,6 @@ class MCPManager:
parts.append(str(item))
return "\n".join(parts) if parts else "(aucun résultat)"
async def _shutdown_async(self) -> None:
for stack in list(self._exit_stacks.values()):
try:
await stack.aclose()
except Exception:
pass
self._sessions.clear()
self._raw_tools.clear()
self._tool_map.clear()
self._exit_stacks.clear()
_manager: MCPManager | None = None
_lock = threading.Lock()
@@ -185,4 +243,6 @@ def reset_manager() -> None:
with _lock:
if _manager is not None:
_manager.shutdown()
_manager = MCPManager()
_manager = MCPManager()