From 9f56989cc2563c82c0ac299307130e8c5a9a9905 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 19 Jul 2026 11:26:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20noms=20d'outils=20=C3=A0=20tirets?= =?UTF-8?q?=20(Context7)=20+=20read=5Ffile=20sur=20dossier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les noms MCP à tirets (resolve-library-id) cassaient le tool-calling : modèles et grammaires mélangent tirets et underscores -> « Tool not found ». Les noms exposés sont assainis en underscores avec table de correspondance vers le vrai nom, et l'appel tolère les deux formes. read_file sur un dossier disait « fichier introuvable » : message explicite « c'est un dossier — utilise list_dir ». Co-Authored-By: Claude Opus 4.8 --- backend/app/mcp_client.py | 57 ++++++++++++++++++++++++++----- backend/app/tools.py | 4 +++ backend/tests/test_mcp_manager.py | 16 ++++++++- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/backend/app/mcp_client.py b/backend/app/mcp_client.py index c05beb4..15b96bf 100644 --- a/backend/app/mcp_client.py +++ b/backend/app/mcp_client.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio import logging +import re import shlex from contextlib import AsyncExitStack @@ -103,6 +104,17 @@ _CONNECT_TIMEOUT = 90.0 _MAX_RESULT_CHARS = 8000 +def _safe_tool_name(name: str) -> str: + """Nom d'outil compatible function-calling (lettres/chiffres/underscore). + + Les noms MCP peuvent contenir des tirets (« resolve-library-id ») que les + grammaires de tool-calling et les modèles mélangent avec des underscores — + source de « Tool not found ». On expose une version assainie et on garde + la correspondance vers le vrai nom. + """ + return re.sub(r"[^a-zA-Z0-9_]", "_", name) + + class _ServerConn: """Session vivante vers un serveur MCP (process stdio + handshake).""" @@ -111,6 +123,8 @@ class _ServerConn: self.stack = AsyncExitStack() self.session: ClientSession | None = None self.tools: list[dict] = [] # définitions format Ollama + # nom exposé au modèle -> vrai nom d'outil MCP + self.name_map: dict[str, str] = {} async def start(self) -> None: entry = CATALOG[self.sid] @@ -151,19 +165,22 @@ class _ServerConn: await asyncio.wait_for(self.session.initialize(), _CONNECT_TIMEOUT) listed = await asyncio.wait_for(self.session.list_tools(), _CONNECT_TIMEOUT) expose = entry.get("expose") - self.tools = [ - { + self.tools = [] + self.name_map = {} + for t in listed.tools: + if expose is not None and t.name not in expose: + continue + exposed = f"mcp_{self.sid}_{_safe_tool_name(t.name)}" + self.name_map[exposed] = t.name + self.tools.append({ "type": "function", "function": { - "name": f"mcp_{self.sid}_{t.name}", + "name": exposed, "description": (t.description or t.name)[:400], "parameters": t.inputSchema or {"type": "object", "properties": {}}, }, - } - for t in listed.tools - if expose is None or t.name in expose - ] + }) async def close(self) -> None: try: @@ -222,17 +239,39 @@ class McpManager: defs.extend(conn.tools) return defs - async def call_tool(self, prefixed_name: str, args: dict) -> dict: - # mcp__ ; sid ne contient pas de "_", le nom d'outil peut. + def _resolve(self, prefixed_name: str) -> tuple[str, str] | None: + """(sid, vrai nom d'outil) depuis le nom exposé au modèle. + + Résolution par table de correspondance, avec tolérance : les modèles + confondent parfois tirets et underscores dans les noms d'outils. + """ + wanted = _safe_tool_name(prefixed_name) + for sid, conn in self._conns.items(): + for exposed, real in conn.name_map.items(): + if exposed == prefixed_name or exposed == wanted: + return sid, real + # Repli : découpage mcp__ (serveur pas encore connecté). try: _, sid, tool = prefixed_name.split("_", 2) + return sid, tool except ValueError: + return None + + async def call_tool(self, prefixed_name: str, args: dict) -> dict: + resolved = self._resolve(prefixed_name) + if resolved is None: return {"ok": False, "content": "", "summary": "nom d'outil invalide"} + sid, tool = resolved async with self._lock: conn = self._conns.get(sid) or await self._ensure(sid) if conn is None or conn.session is None: return {"ok": False, "content": "", "summary": f"serveur MCP {sid} indisponible"} + # Serveur (re)connecté après le repli : re-résout via sa table. + if conn.name_map: + tool = conn.name_map.get(prefixed_name) or conn.name_map.get( + _safe_tool_name(prefixed_name), tool + ) try: result = await asyncio.wait_for( conn.session.call_tool(tool, args or {}), _CALL_TIMEOUT diff --git a/backend/app/tools.py b/backend/app/tools.py index 5bc8c44..40227ec 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -72,6 +72,10 @@ _READ_MAX_CHARS = 12_000 def read_file(path: str, start_line: int = 1) -> dict: target = _safe_path(path) + if os.path.isdir(target): + raise ToolError( + f"{path} est un dossier — utilise list_dir pour voir son contenu" + ) if not os.path.isfile(target): raise ToolError(f"fichier introuvable : {path}") with open(target, "r", encoding="utf-8", errors="replace") as f: diff --git a/backend/tests/test_mcp_manager.py b/backend/tests/test_mcp_manager.py index 9a170ed..1a449c3 100644 --- a/backend/tests/test_mcp_manager.py +++ b/backend/tests/test_mcp_manager.py @@ -12,7 +12,8 @@ from app import db, mcp_client # noqa: E402 db.init_db() -# Serveur MCP minimal : un outil "echo" qui renvoie son argument. +# Serveur MCP minimal : un outil "echo" + un outil au nom À TIRETS +# (comme Context7 « resolve-library-id »). _FAKE_SERVER = textwrap.dedent(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("fake") @@ -22,6 +23,11 @@ _FAKE_SERVER = textwrap.dedent(""" \"\"\"Répète le texte fourni.\"\"\" return "echo:" + text + @mcp.tool(name="dash-tool-name") + def dash_tool(text: str) -> str: + \"\"\"Outil au nom à tirets.\"\"\" + return "dash:" + text + mcp.run() """) @@ -46,9 +52,17 @@ async def test_tools_exposed_and_called(fake_server_cmd, monkeypatch): defs = await mgr.tool_definitions() names = [d["function"]["name"] for d in defs] assert "mcp_fake_echo" in names + # Nom à tirets exposé assaini (compatibilité function-calling). + assert "mcp_fake_dash_tool_name" in names result = await mgr.call_tool("mcp_fake_echo", {"text": "bonjour"}) assert result["ok"] is True assert "echo:bonjour" in result["content"] + # Appel via le nom assaini -> résolu vers le vrai nom à tirets. + dash = await mgr.call_tool("mcp_fake_dash_tool_name", {"text": "x"}) + assert dash["ok"] is True and "dash:x" in dash["content"] + # Tolérance : le modèle répond avec des tirets au lieu d'underscores. + mixed = await mgr.call_tool("mcp_fake_dash-tool-name", {"text": "y"}) + assert mixed["ok"] is True and "dash:y" in mixed["content"] finally: await mgr.aclose() mcp_client.set_mcp_state("fake", enabled=False, params={})