feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
{secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
→ tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
text/event-stream), garde SSRF, outils cachés en base (tools_json) —
le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
This commit is contained in:
@@ -1115,6 +1115,36 @@ class ConnectorFetch(Tool):
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
class McpTool(Tool):
|
||||
"""Outil MCP dynamique — instance construite à la volée depuis le cache
|
||||
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
|
||||
|
||||
def __init__(self, entry: dict):
|
||||
self.name = entry["name"]
|
||||
self.description = entry.get("description") or "Outil MCP"
|
||||
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
|
||||
self.entry = entry
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services import mcp_client
|
||||
|
||||
try:
|
||||
res = await mcp_client.call_tool(self.entry["connector_id"],
|
||||
self.entry.get("original") or "", args)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||
logger.warning("MCP tool failed (%s): %s", self.name, exc)
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"MCP indisponible: {exc}")
|
||||
if res.get("status") != "ok":
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=(res.get("text") or "erreur MCP")[:500])
|
||||
text = res.get("text") or ""
|
||||
return ToolResult(status="success", tool=self.name, target_type="mcp",
|
||||
message=text[:800], data={"text": text})
|
||||
|
||||
|
||||
TOOL_CLASSES = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
@@ -1133,24 +1163,41 @@ class ToolRegistry:
|
||||
def __init__(self):
|
||||
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
||||
|
||||
def _all(self) -> dict[str, Tool]:
|
||||
"""Outils statiques + **outils MCP dynamiques** (cache en base).
|
||||
|
||||
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
|
||||
donc « Tester le connecteur » suffit à rendre un outil disponible.
|
||||
"""
|
||||
tools = dict(self.tools)
|
||||
try:
|
||||
from app.services import mcp_client
|
||||
for entry in mcp_client.cached_tools():
|
||||
tools[entry["name"]] = McpTool(entry)
|
||||
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
||||
logger.exception("MCP dynamic tools load failed")
|
||||
return tools
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
tools = self._all()
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
return list(tools.keys())
|
||||
return [n for n in tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
tools = self._all()
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.tools[n].parameters}
|
||||
{"name": tools[n].name,
|
||||
"description": tools[n].description,
|
||||
"parameters": tools[n].parameters}
|
||||
for n in self.list(scope)
|
||||
]
|
||||
|
||||
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
||||
impl = self.tools.get(tool)
|
||||
impl = self._all().get(tool)
|
||||
if not impl:
|
||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||
return await impl.execute(args, user_id=user_id)
|
||||
|
||||
Reference in New Issue
Block a user