icycache 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icycache/__init__.py +32 -0
- icycache/__main__.py +9 -0
- icycache/cli.py +171 -0
- icycache/config/icycache.gateway.json +111 -0
- icycache/config/icycache.json +149 -0
- icycache/config/normalization_rules.json +5832 -0
- icycache/config/semantic_cache.json +8 -0
- icycache/config/tools_registry.json +10 -0
- icycache/src/icy_cache_store.py +217 -0
- icycache/src/icy_core.py +380 -0
- icycache/src/icy_normalize.py +197 -0
- icycache/src/icy_paths.py +76 -0
- icycache/src/icy_semantic.py +197 -0
- icycache/src/mcp_gateway.py +360 -0
- icycache/src/mcp_server.py +1319 -0
- icycache/src/mcp_stdio_bridge.py +83 -0
- icycache/src/mcp_stdio_entry.py +23 -0
- icycache/src/prefix_proxy.py +336 -0
- icycache/src/server.py +1142 -0
- icycache/src/tool_proxy.py +655 -0
- icycache/src/upstream_client.py +98 -0
- icycache/src/upstream_utils.py +135 -0
- icycache/static/dashboard.html +2385 -0
- icycache/static/icywebgl.js +2190 -0
- icycache/static/logo.svg +58 -0
- icycache/static/three.min.js +6 -0
- icycache-0.3.2.dist-info/METADATA +422 -0
- icycache-0.3.2.dist-info/RECORD +31 -0
- icycache-0.3.2.dist-info/WHEEL +4 -0
- icycache-0.3.2.dist-info/entry_points.txt +2 -0
- icycache-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""IcyCache - proxy d'outils MCP avec cache + garde anti-boucle.
|
|
3
|
+
|
|
4
|
+
Routage: Hermes -> IcyCache (proxy_tool) -> serveur MCP cible (stdio) -> resultat
|
|
5
|
+
Cache: table tool_cache (tc_get/tc_set). Cle = sha256(server, tool, args) -> reponses
|
|
6
|
+
re-utilisables telles quelles. Aucune estimation de tokens: seule la latence
|
|
7
|
+
economisee est mesuree (agent_events.saved_latency_ms).
|
|
8
|
+
Garde: ToolLoopGuard - bloque un agent qui rappelle le meme outil avec les memes args
|
|
9
|
+
ET le meme resultat (boucle infinie). Detecte aussi dans l'historique LLM
|
|
10
|
+
(llm_loop_check).
|
|
11
|
+
Metriques: agent_events (tool_call / tool_hit / tool_error / tool_blocked).
|
|
12
|
+
|
|
13
|
+
Points de robustesse (verifies en production) :
|
|
14
|
+
- Sessions persistantes par serveur : un appel d'outil ne respawn plus un processus
|
|
15
|
+
a chaque fois (20s+ de latence observee avec npx/uvx).
|
|
16
|
+
- Les erreurs MCP (isError=true) ne sont JAMAIS mises en cache et renvoient ok=false.
|
|
17
|
+
- Les outils d'ecriture (create_/write_/delete_/...) ne sont pas mis en cache par
|
|
18
|
+
defaut : une reponse en cache doit pouvoir etre rejouee sans effet de bord.
|
|
19
|
+
- Les commandes Windows (.cmd comme npx) sont resolues : CreateProcess ne lance
|
|
20
|
+
pas un script .cmd/.ps1 sans shell.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("IcyCache.tool_proxy")
|
|
37
|
+
|
|
38
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_config_file() -> Path:
|
|
42
|
+
"""Fichier de config du proxy d'outils.
|
|
43
|
+
|
|
44
|
+
Le proxy d'outils a SON propre fichier (config/icycache.json). Il ne doit pas
|
|
45
|
+
suivre aveuglement ICYCACHE_CONFIG: mcp_gateway.py positionne cette variable
|
|
46
|
+
sur icycache.gateway.json (qui n'a pas de section "tool_proxy"), ce qui
|
|
47
|
+
vidait silencieusement la liste des serveurs routables.
|
|
48
|
+
"""
|
|
49
|
+
explicit = os.environ.get("ICYCACHE_TOOLPROXY_CONFIG")
|
|
50
|
+
if explicit:
|
|
51
|
+
return Path(explicit)
|
|
52
|
+
default = PROJECT_ROOT / "config" / "icycache.json"
|
|
53
|
+
env_cfg = os.environ.get("ICYCACHE_CONFIG")
|
|
54
|
+
if env_cfg:
|
|
55
|
+
p = Path(env_cfg)
|
|
56
|
+
try:
|
|
57
|
+
if "tool_proxy" in json.loads(p.read_text(encoding="utf-8")):
|
|
58
|
+
return p
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
CONFIG_FILE = _resolve_config_file()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_config() -> Dict[str, Any]:
|
|
68
|
+
try:
|
|
69
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
|
|
70
|
+
return json.load(fh)
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception("tool_proxy: impossible de lire %s", CONFIG_FILE)
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
CONFIG = _load_config()
|
|
77
|
+
TOOL_CACHE_CFG: Dict[str, Any] = CONFIG.get("tool_cache") or {}
|
|
78
|
+
TOOL_PROXY_CFG: Dict[str, Any] = CONFIG.get("tool_proxy") or {}
|
|
79
|
+
TOOL_SERVERS: Dict[str, Dict[str, Any]] = TOOL_PROXY_CFG.get("servers") or {}
|
|
80
|
+
DEFAULT_TTL = int(TOOL_CACHE_CFG.get("ttl_seconds", 3600))
|
|
81
|
+
CACHE_ENABLED = bool(TOOL_CACHE_CFG.get("enabled", True))
|
|
82
|
+
|
|
83
|
+
HERMES_CONFIG = Path(os.environ.get("LOCALAPPDATA") or ".") / "hermes" / "config.yaml"
|
|
84
|
+
|
|
85
|
+
# Heuristique "outil d'ecriture" : un resultat mis en cache doit pouvoir etre rejoue
|
|
86
|
+
# sans effet de bord. Un create_/delete_/push_ ne peut pas l'etre.
|
|
87
|
+
_WRITE_HINTS = (
|
|
88
|
+
"create", "write", "update", "delete", "remove", "destroy", "drop", "purge",
|
|
89
|
+
"push", "commit", "merge", "send", "post", "put", "patch", "edit", "save",
|
|
90
|
+
"upload", "download", "install", "uninstall", "publish", "apply", "move",
|
|
91
|
+
"rename", "rebuild", "reset", "clear", "flush", "exec", "execute", "run_",
|
|
92
|
+
"add_", "set_", "new_", "insert", "deploy", "build", "start_", "stop_", "kill",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_write_tool(tool: str) -> bool:
|
|
97
|
+
t = (tool or "").lower()
|
|
98
|
+
return any(h in t for h in _WRITE_HINTS)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_env_value(val: str) -> str:
|
|
102
|
+
"""Resolution paresseuse d'un secret vers la config Hermes.
|
|
103
|
+
|
|
104
|
+
Format: "@hermes:mcp_servers.<nom>" -> valeur lue dans %LOCALAPPDATA%\\hermes\\config.yaml.
|
|
105
|
+
Si le noeud est un dict avec 'args', on extrait la valeur du premier arg contenant '='.
|
|
106
|
+
Evite d'ecrire un token dans la config IcyCache.
|
|
107
|
+
"""
|
|
108
|
+
if not isinstance(val, str) or not val.startswith("@hermes:"):
|
|
109
|
+
return val
|
|
110
|
+
try:
|
|
111
|
+
import yaml
|
|
112
|
+
except ImportError as exc:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"PyYAML est requis pour resoudre '@hermes:' (pip install pyyaml). "
|
|
115
|
+
"Sinon, mettez la valeur en clair dans la config IcyCache."
|
|
116
|
+
) from exc
|
|
117
|
+
path = val[len("@hermes:"):]
|
|
118
|
+
with open(HERMES_CONFIG, "r", encoding="utf-8") as fh:
|
|
119
|
+
cfg = yaml.safe_load(fh)
|
|
120
|
+
node: Any = cfg
|
|
121
|
+
for part in path.split("."):
|
|
122
|
+
node = node[part]
|
|
123
|
+
if isinstance(node, dict):
|
|
124
|
+
for a in node.get("args") or []:
|
|
125
|
+
if isinstance(a, str) and "=" in a:
|
|
126
|
+
return a.split("=", 1)[1]
|
|
127
|
+
raise KeyError(f"aucun secret resolvable dans {path}")
|
|
128
|
+
return str(node)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _env_for(cfg: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
|
132
|
+
raw = cfg.get("env")
|
|
133
|
+
if not raw:
|
|
134
|
+
return None
|
|
135
|
+
return {k: _resolve_env_value(v) for k, v in raw.items()}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Import apres la lecture de la config: icy_core lit le meme fichier et initialise la DB.
|
|
139
|
+
from icy_core import ( # noqa: E402
|
|
140
|
+
tc_get,
|
|
141
|
+
tc_set,
|
|
142
|
+
tc_flush,
|
|
143
|
+
tc_clear,
|
|
144
|
+
tc_stats,
|
|
145
|
+
log_agent_event,
|
|
146
|
+
llm_loop_check,
|
|
147
|
+
ToolLoopGuard,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
GUARD = ToolLoopGuard(TOOL_CACHE_CFG)
|
|
151
|
+
|
|
152
|
+
_TOOL_LIST_CACHE: Dict[str, tuple] = {}
|
|
153
|
+
_TOOL_LIST_TTL = float(TOOL_PROXY_CFG.get("list_cache_seconds", 30))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# RESOLUTION DE COMMANDE (Windows)
|
|
158
|
+
# =============================================================================
|
|
159
|
+
def _resolve_command(cmd: str) -> str:
|
|
160
|
+
"""Resout une commande d'executable de façon compatible CreateProcess.
|
|
161
|
+
|
|
162
|
+
Sous Windows, `npx` seul pointe vers le script sans extension (bash) et
|
|
163
|
+
`npx.ps1` n'est pas executable sans shell: CreateProcess echoue avec
|
|
164
|
+
WinError 2. On prefere donc `<cmd>.cmd`, puis `<cmd>.exe`, puis `<cmd>.bat`.
|
|
165
|
+
"""
|
|
166
|
+
if not cmd or os.name != "nt":
|
|
167
|
+
return cmd
|
|
168
|
+
if os.path.isabs(cmd) or (os.path.altsep and os.path.altsep in cmd):
|
|
169
|
+
return cmd
|
|
170
|
+
if os.path.splitext(cmd)[1]:
|
|
171
|
+
return cmd
|
|
172
|
+
for ext in (".cmd", ".exe", ".bat", ".com"):
|
|
173
|
+
found = shutil.which(cmd + ext)
|
|
174
|
+
if found:
|
|
175
|
+
return found
|
|
176
|
+
return cmd
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _venv_python() -> str:
|
|
180
|
+
"""Interpreteur Python du venv du projet (le seul qui a les dependances)."""
|
|
181
|
+
if os.name == "nt":
|
|
182
|
+
cand = PROJECT_ROOT / "venv" / "Scripts" / "python.exe"
|
|
183
|
+
else:
|
|
184
|
+
cand = PROJECT_ROOT / "venv" / "bin" / "python"
|
|
185
|
+
return str(cand) if cand.exists() else sys.executable
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _expand(value: str) -> str:
|
|
189
|
+
"""Remplace les jetons de portabilite dans la config.
|
|
190
|
+
|
|
191
|
+
{PROJECT_ROOT} -> racine du projet {PYTHON} -> interpreteur du venv
|
|
192
|
+
Permet de deplacer IcyCache sans editer la config a la main.
|
|
193
|
+
"""
|
|
194
|
+
if not isinstance(value, str):
|
|
195
|
+
return value
|
|
196
|
+
return (value
|
|
197
|
+
.replace("{PROJECT_ROOT}", str(PROJECT_ROOT))
|
|
198
|
+
.replace("{PYTHON}", _venv_python()))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _stdio_params(cfg: Dict[str, Any]):
|
|
202
|
+
from mcp import StdioServerParameters
|
|
203
|
+
return StdioServerParameters(
|
|
204
|
+
command=_resolve_command(_expand(str(cfg["command"]))),
|
|
205
|
+
args=[_expand(a) for a in (cfg.get("args") or [])],
|
|
206
|
+
env=_env_for(cfg),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _describe_exc(exc: BaseException) -> str:
|
|
211
|
+
"""Message d'erreur utile: anyio encapsule la vraie erreur dans un
|
|
212
|
+
ExceptionGroup ('unhandled errors in a TaskGroup'), ce qui masque la cause."""
|
|
213
|
+
parts: List[str] = []
|
|
214
|
+
|
|
215
|
+
def walk(e: BaseException, depth: int = 0) -> None:
|
|
216
|
+
if isinstance(e, BaseExceptionGroup):
|
|
217
|
+
for sub in e.exceptions:
|
|
218
|
+
walk(sub, depth + 1)
|
|
219
|
+
else:
|
|
220
|
+
msg = str(e).strip()
|
|
221
|
+
parts.append(f"{type(e).__name__}: {msg}" if msg else type(e).__name__)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
walk(exc)
|
|
225
|
+
except Exception:
|
|
226
|
+
parts = []
|
|
227
|
+
if not parts:
|
|
228
|
+
parts = [f"{type(exc).__name__}: {exc}"]
|
|
229
|
+
return " | ".join(parts)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# =============================================================================
|
|
233
|
+
# SESSION PERSISTANTE PAR SERVEUR
|
|
234
|
+
# =============================================================================
|
|
235
|
+
class _ServerConn:
|
|
236
|
+
"""Session MCP vivante pour un serveur cible.
|
|
237
|
+
|
|
238
|
+
`_exec()` ouvrait un processus + un handshake `initialize` a CHAQUE appel
|
|
239
|
+
d'outil (mesure: 21s pour npx, 12s pour uvx qui installe a chaque fois).
|
|
240
|
+
On garde donc une session par serveur, dans son propre thread + event loop,
|
|
241
|
+
et on y planifie les appels via run_coroutine_threadsafe. Le exit du task
|
|
242
|
+
group anyio reste dans la tache qui l'a ouvert, comme l'exige le SDK.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, name: str, cfg: Dict[str, Any]):
|
|
246
|
+
self.name = name
|
|
247
|
+
self.cfg = cfg
|
|
248
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
249
|
+
self._thread: Optional[threading.Thread] = None
|
|
250
|
+
self._session = None
|
|
251
|
+
self._ready = threading.Event()
|
|
252
|
+
self._error: Optional[BaseException] = None
|
|
253
|
+
self._lock = threading.Lock()
|
|
254
|
+
|
|
255
|
+
# -- demarrage ---------------------------------------------------------
|
|
256
|
+
def start(self, timeout: float = 60.0) -> None:
|
|
257
|
+
with self._lock:
|
|
258
|
+
if self._thread is not None and self._thread.is_alive() and self._session is not None:
|
|
259
|
+
return
|
|
260
|
+
self._ready.clear()
|
|
261
|
+
self._error = None
|
|
262
|
+
self._thread = threading.Thread(
|
|
263
|
+
target=self._run, daemon=True, name="icy-mcp-%s" % self.name)
|
|
264
|
+
self._thread.start()
|
|
265
|
+
if not self._ready.wait(timeout):
|
|
266
|
+
raise TimeoutError(f"demarrage du serveur MCP {self.name!r} trop long (> {timeout:.0f}s)")
|
|
267
|
+
if self._error is not None:
|
|
268
|
+
raise self._error
|
|
269
|
+
|
|
270
|
+
def _run(self) -> None:
|
|
271
|
+
loop = asyncio.new_event_loop()
|
|
272
|
+
asyncio.set_event_loop(loop)
|
|
273
|
+
self._loop = loop
|
|
274
|
+
try:
|
|
275
|
+
loop.run_until_complete(self._serve())
|
|
276
|
+
except BaseException as exc: # noqa: BLE001 - remonte a start()
|
|
277
|
+
self._error = exc
|
|
278
|
+
finally:
|
|
279
|
+
self._ready.set()
|
|
280
|
+
try:
|
|
281
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
loop.close()
|
|
285
|
+
|
|
286
|
+
async def _serve(self) -> None:
|
|
287
|
+
from mcp import ClientSession
|
|
288
|
+
from mcp.client.stdio import stdio_client
|
|
289
|
+
|
|
290
|
+
params = _stdio_params(self.cfg)
|
|
291
|
+
async with stdio_client(params) as (reader, writer):
|
|
292
|
+
async with ClientSession(reader, writer) as session:
|
|
293
|
+
await session.initialize()
|
|
294
|
+
self._session = session
|
|
295
|
+
self._ready.set()
|
|
296
|
+
while self._session is session:
|
|
297
|
+
await asyncio.sleep(0.25)
|
|
298
|
+
|
|
299
|
+
# -- appels ------------------------------------------------------------
|
|
300
|
+
def _submit(self, coro_factory, timeout: float):
|
|
301
|
+
loop = self._loop
|
|
302
|
+
session = self._session
|
|
303
|
+
if loop is None or session is None:
|
|
304
|
+
raise RuntimeError(f"session MCP {self.name!r} non prete")
|
|
305
|
+
fut = asyncio.run_coroutine_threadsafe(coro_factory(session), loop)
|
|
306
|
+
return fut.result(timeout)
|
|
307
|
+
|
|
308
|
+
def call_tool(self, tool: str, args: Dict[str, Any], timeout: float = 300.0):
|
|
309
|
+
return self._submit(lambda s: s.call_tool(tool, args or {}), timeout)
|
|
310
|
+
|
|
311
|
+
def list_tools(self, timeout: float = 60.0):
|
|
312
|
+
return self._submit(lambda s: s.list_tools(), timeout)
|
|
313
|
+
|
|
314
|
+
def alive(self) -> bool:
|
|
315
|
+
return self._session is not None and self._thread is not None and self._thread.is_alive()
|
|
316
|
+
|
|
317
|
+
def stop(self) -> None:
|
|
318
|
+
# Pas de loop.stop() ni de join (stop() est appele depuis l'event loop principal):
|
|
319
|
+
# _serve() sort seul via `while self._session is session` et ferme
|
|
320
|
+
# ClientSession/stdio_client dans la tache qui les a ouverts (contrat anyio).
|
|
321
|
+
self._session = None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_CONNS: Dict[str, _ServerConn] = {}
|
|
325
|
+
_CONNS_LOCK = threading.Lock()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _conn(server: str) -> _ServerConn:
|
|
329
|
+
with _CONNS_LOCK:
|
|
330
|
+
c = _CONNS.get(server)
|
|
331
|
+
if c is None:
|
|
332
|
+
c = _ServerConn(server, TOOL_SERVERS[server])
|
|
333
|
+
_CONNS[server] = c
|
|
334
|
+
return c
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _drop_conn(server: str) -> None:
|
|
338
|
+
with _CONNS_LOCK:
|
|
339
|
+
c = _CONNS.pop(server, None)
|
|
340
|
+
if c is not None:
|
|
341
|
+
c.stop()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# =============================================================================
|
|
345
|
+
# API PUBLIQUE
|
|
346
|
+
# =============================================================================
|
|
347
|
+
def server_names() -> List[str]:
|
|
348
|
+
return sorted(TOOL_SERVERS)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def tool_cache_key(server: str, tool: str, args: Any) -> str:
|
|
352
|
+
"""Cle de cache = hash exact de (serveur, outil, arguments). Deterministe."""
|
|
353
|
+
raw = json.dumps([server, tool, args if args is not None else {}],
|
|
354
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
355
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _args_hash(args: Any) -> str:
|
|
359
|
+
raw = json.dumps(args if args is not None else {},
|
|
360
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
361
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _content_to_json(res) -> Any:
|
|
365
|
+
"""Resultat MCP -> structure serialisable (json-able)."""
|
|
366
|
+
if res is None:
|
|
367
|
+
return None
|
|
368
|
+
dump = getattr(res, "model_dump", None)
|
|
369
|
+
if callable(dump):
|
|
370
|
+
try:
|
|
371
|
+
return dump(mode="json")
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
out: List[Any] = []
|
|
375
|
+
for blk in getattr(res, "content", []) or []:
|
|
376
|
+
if isinstance(blk, dict):
|
|
377
|
+
out.append(blk)
|
|
378
|
+
else:
|
|
379
|
+
out.append({k: v for k, v in vars(blk).items() if not k.startswith("_")})
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _is_error_result(payload: Any) -> bool:
|
|
384
|
+
"""Detecte un resultat MCP en erreur (isError=true) dans le payload serialise."""
|
|
385
|
+
if isinstance(payload, dict):
|
|
386
|
+
if payload.get("isError") is True:
|
|
387
|
+
return True
|
|
388
|
+
if payload.get("is_error") is True:
|
|
389
|
+
return True
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _error_text(payload: Any) -> str:
|
|
394
|
+
"""Extrait un texte lisible d'un resultat d'erreur MCP."""
|
|
395
|
+
if isinstance(payload, dict):
|
|
396
|
+
chunks: List[str] = []
|
|
397
|
+
for blk in payload.get("content") or []:
|
|
398
|
+
if isinstance(blk, dict) and blk.get("text"):
|
|
399
|
+
chunks.append(str(blk["text"]))
|
|
400
|
+
if chunks:
|
|
401
|
+
return " ".join(chunks)[:800]
|
|
402
|
+
if payload.get("error"):
|
|
403
|
+
return str(payload["error"])[:800]
|
|
404
|
+
return "erreur MCP"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _cache_decision(cfg: Dict[str, Any], tool: str) -> bool:
|
|
408
|
+
"""Le cache d'outils est OPT-IN par serveur, et les outils d'ecriture sont
|
|
409
|
+
exclus par defaut: rejouer une reponse en cache doit etre sans effet de bord.
|
|
410
|
+
|
|
411
|
+
Regles (dans l'ordre):
|
|
412
|
+
1. `cache_tools` present (dict) -> ALLOWLIST stricte: seuls les outils
|
|
413
|
+
listes a true sont caches, tout le reste est execute a chaque fois.
|
|
414
|
+
2. `cache: true` -> heuristique anti-ecriture (les outils create_/write_/
|
|
415
|
+
delete_/push_... ne sont pas caches).
|
|
416
|
+
3. sinon -> pas de cache.
|
|
417
|
+
"""
|
|
418
|
+
if not CACHE_ENABLED:
|
|
419
|
+
return False
|
|
420
|
+
per = cfg.get("cache_tools")
|
|
421
|
+
if isinstance(per, dict):
|
|
422
|
+
return bool(per.get(tool, False))
|
|
423
|
+
if not cfg.get("cache", False):
|
|
424
|
+
return False
|
|
425
|
+
if _is_write_tool(tool):
|
|
426
|
+
allow = cfg.get("cache_write_tools") or []
|
|
427
|
+
return tool in allow
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _exec(server: str, cfg: Dict[str, Any], tool: str, args: Dict[str, Any]):
|
|
432
|
+
"""Execute l'outil. Session persistante si possible, processus jetable sinon."""
|
|
433
|
+
from mcp import ClientSession
|
|
434
|
+
from mcp.client.stdio import stdio_client
|
|
435
|
+
|
|
436
|
+
from mcp.shared.exceptions import McpError
|
|
437
|
+
|
|
438
|
+
conn = _conn(server)
|
|
439
|
+
started = False
|
|
440
|
+
try:
|
|
441
|
+
await asyncio.to_thread(conn.start)
|
|
442
|
+
if not conn.alive():
|
|
443
|
+
raise RuntimeError("session persistante indisponible")
|
|
444
|
+
started = True
|
|
445
|
+
res = await asyncio.to_thread(conn.call_tool, tool, args)
|
|
446
|
+
return _content_to_json(res)
|
|
447
|
+
except McpError:
|
|
448
|
+
# Erreur applicative renvoyee par le serveur (outil inconnu, args invalides...):
|
|
449
|
+
# la session est saine. Ni drop ni rejeu (evite double execution + respawn).
|
|
450
|
+
raise
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
if started and isinstance(exc, TimeoutError):
|
|
453
|
+
# Timeout de l'appel: l'outil peut encore tourner cote serveur -> pas de rejeu.
|
|
454
|
+
raise
|
|
455
|
+
# Session cassee ou demarrage impossible -> on retente en jetable.
|
|
456
|
+
_drop_conn(server)
|
|
457
|
+
logger.warning("tool_proxy: session persistante %s KO (%s), repli jetable", server,
|
|
458
|
+
_describe_exc(exc))
|
|
459
|
+
params = _stdio_params(cfg)
|
|
460
|
+
async with stdio_client(params) as (reader, writer):
|
|
461
|
+
async with ClientSession(reader, writer) as session:
|
|
462
|
+
await session.initialize()
|
|
463
|
+
res = await session.call_tool(tool, args or {})
|
|
464
|
+
return _content_to_json(res)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _list_tools(server: str) -> List[str]:
|
|
468
|
+
"""Liste des outils exposes par le serveur cible (avec cache memoire TTL)."""
|
|
469
|
+
now = time.time()
|
|
470
|
+
hit = _TOOL_LIST_CACHE.get(server)
|
|
471
|
+
if hit and now - hit[0] < _TOOL_LIST_TTL:
|
|
472
|
+
return hit[1]
|
|
473
|
+
|
|
474
|
+
from mcp import ClientSession
|
|
475
|
+
from mcp.client.stdio import stdio_client
|
|
476
|
+
|
|
477
|
+
conn = _conn(server)
|
|
478
|
+
names: Optional[List[str]] = None
|
|
479
|
+
try:
|
|
480
|
+
await asyncio.to_thread(conn.start)
|
|
481
|
+
if conn.alive():
|
|
482
|
+
listing = await asyncio.to_thread(conn.list_tools)
|
|
483
|
+
names = [t.name for t in (getattr(listing, "tools", None) or [])]
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
logger.warning("tool_proxy: liste persistante %s KO (%s), repli jetable", server,
|
|
486
|
+
_describe_exc(exc))
|
|
487
|
+
_drop_conn(server)
|
|
488
|
+
|
|
489
|
+
if names is None:
|
|
490
|
+
params = _stdio_params(TOOL_SERVERS[server])
|
|
491
|
+
async with stdio_client(params) as (reader, writer):
|
|
492
|
+
async with ClientSession(reader, writer) as session:
|
|
493
|
+
await session.initialize()
|
|
494
|
+
listing = await session.list_tools()
|
|
495
|
+
names = [t.name for t in (getattr(listing, "tools", None) or [])]
|
|
496
|
+
|
|
497
|
+
_TOOL_LIST_CACHE[server] = (now, names)
|
|
498
|
+
return names
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async def list_server_tools(server: str) -> Dict[str, Any]:
|
|
502
|
+
if server not in TOOL_SERVERS:
|
|
503
|
+
return {"ok": False, "error": f"serveur inconnu: {server!r}", "servers": server_names()}
|
|
504
|
+
try:
|
|
505
|
+
names = await _list_tools(server)
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
return {"ok": False, "error": _describe_exc(exc)}
|
|
508
|
+
return {"ok": True, "server": server, "tools": names, "count": len(names)}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def call_tool(server: str, tool: str, args: Any = None,
|
|
512
|
+
client: Optional[str] = None) -> Dict[str, Any]:
|
|
513
|
+
"""Appelle un outil MCP via le proxy: cache -> loop guard -> execution -> stockage."""
|
|
514
|
+
args = args if isinstance(args, dict) else {}
|
|
515
|
+
if server not in TOOL_SERVERS:
|
|
516
|
+
return {"ok": False, "error": f"serveur inconnu: {server!r}", "servers": server_names()}
|
|
517
|
+
|
|
518
|
+
cfg = TOOL_SERVERS[server]
|
|
519
|
+
t0 = time.perf_counter()
|
|
520
|
+
cacheable = _cache_decision(cfg, tool)
|
|
521
|
+
key = tool_cache_key(server, tool, args)
|
|
522
|
+
ahash = _args_hash(args)
|
|
523
|
+
|
|
524
|
+
# --- HIT: reponse reutilisable telle quelle ---
|
|
525
|
+
if cacheable:
|
|
526
|
+
row = tc_get(key)
|
|
527
|
+
if row:
|
|
528
|
+
result_json, latency_ms, _bytes = row
|
|
529
|
+
log_agent_event("tool_hit", server=server, tool=tool, client=client,
|
|
530
|
+
args_hash=key, latency_ms=0, saved_latency_ms=latency_ms or 0)
|
|
531
|
+
try:
|
|
532
|
+
payload = json.loads(result_json)
|
|
533
|
+
except Exception:
|
|
534
|
+
payload = result_json
|
|
535
|
+
return {"ok": True, "cached": True, "latency_ms": 0,
|
|
536
|
+
"saved_latency_ms": latency_ms or 0, "result": payload}
|
|
537
|
+
|
|
538
|
+
# --- GARDE ANTI-BOUcle: meme tool + memes args deja bloques ---
|
|
539
|
+
# La cle inclut le serveur: deux serveurs peuvent exposer le meme nom d'outil.
|
|
540
|
+
sess = "%s|%s" % (server, client or "default")
|
|
541
|
+
if GUARD.check_before(sess, tool, ahash):
|
|
542
|
+
GUARD.record_blocked(sess, tool, ahash)
|
|
543
|
+
log_agent_event("tool_blocked", server=server, tool=tool, client=client,
|
|
544
|
+
args_hash=key, detail={"reason": "loop_guard"})
|
|
545
|
+
return {"ok": False,
|
|
546
|
+
"error": "BOUCLE BLOQUEE: meme outil + memes args + memes resultats (ToolLoopGuard)."}
|
|
547
|
+
|
|
548
|
+
# --- MISS: execution reelle ---
|
|
549
|
+
try:
|
|
550
|
+
payload = await _exec(server, cfg, tool, args)
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
log_agent_event("tool_error", server=server, tool=tool, client=client,
|
|
553
|
+
args_hash=key,
|
|
554
|
+
latency_ms=int((time.perf_counter() - t0) * 1000),
|
|
555
|
+
detail={"error": _describe_exc(exc)})
|
|
556
|
+
return {"ok": False, "error": _describe_exc(exc)}
|
|
557
|
+
latency_ms = int((time.perf_counter() - t0) * 1000)
|
|
558
|
+
|
|
559
|
+
# --- Erreur MCP: jamais de cache, jamais de faux "ok" ---
|
|
560
|
+
if _is_error_result(payload):
|
|
561
|
+
msg = _error_text(payload)
|
|
562
|
+
log_agent_event("tool_error", server=server, tool=tool, client=client,
|
|
563
|
+
args_hash=key, latency_ms=latency_ms,
|
|
564
|
+
detail={"error": msg, "source": "mcp_isError"})
|
|
565
|
+
return {"ok": False, "cached": False, "latency_ms": latency_ms,
|
|
566
|
+
"error": msg, "result": payload}
|
|
567
|
+
|
|
568
|
+
result_json = json.dumps(payload, ensure_ascii=False, default=str)
|
|
569
|
+
rhash = hashlib.sha256(result_json.encode("utf-8")).hexdigest()[:16]
|
|
570
|
+
level, cycle_len, reps = GUARD.after(sess, tool, ahash, rhash)
|
|
571
|
+
|
|
572
|
+
if cacheable:
|
|
573
|
+
ttl = int(cfg.get("ttl_seconds", DEFAULT_TTL))
|
|
574
|
+
try:
|
|
575
|
+
tc_set(key, server, tool, result_json, len(result_json.encode("utf-8")), latency_ms, ttl)
|
|
576
|
+
except Exception:
|
|
577
|
+
logger.exception("tc_set a echoue pour %s/%s", server, tool)
|
|
578
|
+
|
|
579
|
+
log_agent_event("tool_call", server=server, tool=tool, client=client,
|
|
580
|
+
args_hash=key, nbytes=len(result_json.encode("utf-8")),
|
|
581
|
+
latency_ms=latency_ms, detail={"cached": False, "cacheable": cacheable})
|
|
582
|
+
|
|
583
|
+
out: Dict[str, Any] = {"ok": True, "cached": False, "latency_ms": latency_ms, "result": payload}
|
|
584
|
+
if level in ("warn", "block"):
|
|
585
|
+
out["loop_warning"] = {"level": level, "cycle_len": cycle_len, "reps": reps}
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
async def batch_call(calls: List[Dict[str, Any]],
|
|
590
|
+
client: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
591
|
+
"""Appel batch: [{'server':..,'tool':..,'args':{..}}, ...]."""
|
|
592
|
+
out: List[Dict[str, Any]] = []
|
|
593
|
+
calls = calls or []
|
|
594
|
+
if calls and all(isinstance(c, dict) for c in calls):
|
|
595
|
+
# Les appels vers des serveurs differents sont independants -> en parallele.
|
|
596
|
+
# Un meme serveur reste serialise (session MCP partagee, pas de concurrence).
|
|
597
|
+
groups: Dict[str, List[Dict[str, Any]]] = {}
|
|
598
|
+
for c in calls:
|
|
599
|
+
groups.setdefault(str(c.get("server")), []).append(c)
|
|
600
|
+
if len(groups) > 1:
|
|
601
|
+
results = await asyncio.gather(
|
|
602
|
+
*[call_tool(c.get("server"), c.get("tool"), c.get("args") or {}, client)
|
|
603
|
+
for c in calls],
|
|
604
|
+
return_exceptions=True)
|
|
605
|
+
for c, r in zip(calls, results):
|
|
606
|
+
if isinstance(r, BaseException):
|
|
607
|
+
out.append({"ok": False, "error": _describe_exc(r)})
|
|
608
|
+
else:
|
|
609
|
+
out.append(r)
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
for call in calls:
|
|
613
|
+
if not isinstance(call, dict):
|
|
614
|
+
out.append({"ok": False, "error": "call invalide (dict attendu)"})
|
|
615
|
+
continue
|
|
616
|
+
out.append(await call_tool(call.get("server"), call.get("tool"),
|
|
617
|
+
call.get("args") or {}, client))
|
|
618
|
+
return out
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def llm_messages_guard(messages: Any, warn_after: int = 3,
|
|
622
|
+
max_cycle: int = 3) -> Optional[Dict[str, Any]]:
|
|
623
|
+
"""Boucle d'agent detectee dans l'historique LLM (memes tool calls + memes resultats)."""
|
|
624
|
+
return llm_loop_check(messages, warn_after=warn_after, max_cycle=max_cycle)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def clear_cache(server: Optional[str] = None) -> Dict[str, Any]:
|
|
628
|
+
try:
|
|
629
|
+
n = tc_clear(server)
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
return {"ok": False, "error": _describe_exc(exc)}
|
|
632
|
+
return {"ok": True, "cleared": n, "server": server or "all"}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def flush_server(server: str) -> Dict[str, Any]:
|
|
636
|
+
try:
|
|
637
|
+
n = tc_flush(server)
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
return {"ok": False, "error": _describe_exc(exc)}
|
|
640
|
+
return {"ok": True, "cleared": n, "server": server}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def stats() -> Dict[str, Any]:
|
|
644
|
+
try:
|
|
645
|
+
return {"ok": True, **tc_stats()}
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
return {"ok": False, "error": _describe_exc(exc)}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def connection_status() -> Dict[str, Any]:
|
|
651
|
+
"""Etat des sessions persistantes (diagnostic)."""
|
|
652
|
+
with _CONNS_LOCK:
|
|
653
|
+
items = list(_CONNS.values())
|
|
654
|
+
return {c.name: {"alive": c.alive(), "error": _describe_exc(c._error) if c._error else None}
|
|
655
|
+
for c in items}
|