mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
7d1fa8913d
commit
9f56989cc2
3 files changed
+67
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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={})
|
||||
|
||||
Reference in new issue
Block a user