fix(mcp): noms d'outils à tirets (Context7) + read_file sur dossier

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-19 11:26:27 +02:00
1 parent 7d1fa8913d
commit 9f56989cc2
3 files changed
+67 -10

No files matched your search

+48 -9
View File
@@ -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>_<tool> ; 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_<sid>_<outil> (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
+4
View File
@@ -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:
+15 -1
View File
@@ -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={})