Add gitignor
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user