Files
Loki/internal/loki/mcp_config.go
T
MichaelandClaude Opus 5.5 2e28ca955e MCP : délais de connexion et d'appel réglables dans l'interface
Repris d'AJEAN c05a0a9f et e44b6139. Les deux délais MCP, jusqu'ici codés en
dur (20 s pour se connecter, 120 s pour un appel d'outil), deviennent des
réglages globaux : clé bkState mcp_timeouts, bornes 1 à 3600 s, routes
GET /api/mcp/timeouts et POST /api/mcp/timeouts/save. L'enregistrement ferme
les sessions ouvertes pour que le nouveau délai de connexion s'applique dès
la suivante. Un lancement npx/uvx garde au moins mcpConnectTimeoutCold.

Un appel prend le réglage global, toujours remplacé par le callTimeoutSec du
serveur, puis allongé par l'argument timeout de l'outil. Le plafond d'un
appel suit la borne des réglages (1 h au lieu de 30 min) : un délai réglé à
3600 s n'est plus rogné en silence. Une valeur rangée hors bornes ou
illisible retombe sur la borne ou le défaut.

Le délai par serveur passe par /api/mcp/save (callTimeoutSec, 0 à 3600,
vide = réglage global), revient dans /api/mcp et n'est plus perdu à
l'enregistrement. L'interface gagne deux lignes dans la carte « Serveurs
MCP », au style des réglages (sans bordure, un seul enregistrement par
modification), et un champ « délai d'un appel d'outil » dans la fenêtre
d'édition d'un serveur. index.html régénéré par tools/assemble-ui.

Quand la façade tient les serveurs MCP (LOKI_MCP=rust), elle sert aussi
les nouvelles routes : c'est son pool qu'il faut fermer quand les délais
changent. Le journal de parité MCP couvre la lecture des délais, leur effet
sur la connexion et l'appel, et les deux routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:14 +02:00

264 lines
7.8 KiB
Go

package loki
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
)
// Configuration des serveurs MCP (Model Context Protocol).
//
// loki peut se connecter à des serveurs MCP tiers pour enrichir la palette
// d'outils de l'IA (accès fichiers rapide type desktop-commander, bases de
// données, APIs métier…). Deux transports :
//
// - stdio : loki lance un process local (command + args) et parle en
// JSON-RPC sur stdin/stdout. C'est le cas de desktop-commander, du serveur
// filesystem officiel, etc. — lancés via npx/uvx/un binaire.
// - http : loki parle à un serveur MCP distant en Streamable HTTP (url +
// éventuels en-têtes d'auth).
//
// Le format du fichier mcp.json reprend celui de Claude Desktop (clé
// "mcpServers" indexée par nom) pour que les utilisateurs puissent copier-coller
// leurs configs existantes. On ajoute un champ "enabled" par serveur.
//
// IMPORTANT (sécurité) : un serveur MCP stdio exécute un process arbitraire sur
// la machine où tourne loki — même niveau de confiance que l'outil `bash` du
// mode agent. La configuration MCP est donc réservée au propriétaire local de la
// machine et ne doit JAMAIS être pilotable depuis le relais/accès distant.
// MCPServerConfig décrit un serveur MCP configuré. Un seul des deux transports
// est renseigné : Command => stdio, URL => http.
type MCPServerConfig struct {
// Transport stdio.
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Transport http (Streamable HTTP).
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
// Enabled : le serveur n'est connecté et ses outils exposés que s'il est
// activé. Un serveur nouvellement ajouté est actif par défaut (voir
// UnmarshalJSON) pour coller à l'intuition « je l'ajoute, il marche ».
Enabled bool `json:"enabled"`
// DisabledTools : outils du serveur à NE PAS exposer à l'IA (par nom réel,
// non namespacé). Permet de garder un serveur connecté tout en masquant
// certains de ses outils. Vide = tous les outils exposés.
DisabledTools []string `json:"disabledTools,omitempty"`
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
// 0 = réglage global (MCPTimeouts.Call). Pour un serveur dont les outils sont longs
// par nature (build, scraping, requête lourde).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
func (c MCPServerConfig) ToolDisabled(tool string) bool {
for _, t := range c.DisabledTools {
if t == tool {
return true
}
}
return false
}
// enabledDefaultTrue est un alias utilisé pour appliquer enabled=true par défaut
// quand le champ est absent du JSON (compat configs Claude Desktop sans
// "enabled").
type mcpServerConfigAlias MCPServerConfig
// UnmarshalJSON applique enabled=true par défaut lorsque la clé est absente,
// pour rester compatible avec les fichiers mcp.json qui ne connaissent pas ce
// champ (Claude Desktop, etc.).
func (c *MCPServerConfig) UnmarshalJSON(b []byte) error {
// Sonde la présence de la clé "enabled".
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return err
}
alias := mcpServerConfigAlias{}
if err := json.Unmarshal(b, &alias); err != nil {
return err
}
if _, ok := probe["enabled"]; !ok {
alias.Enabled = true
}
*c = MCPServerConfig(alias)
return nil
}
// Transport renvoie "stdio", "http" ou "" (mal configuré : ni command ni url).
func (c MCPServerConfig) Transport() string {
switch {
case strings.TrimSpace(c.Command) != "":
return "stdio"
case strings.TrimSpace(c.URL) != "":
return "http"
default:
return ""
}
}
// Validate vérifie qu'exactement un transport est renseigné.
func (c MCPServerConfig) Validate() error {
hasCmd := strings.TrimSpace(c.Command) != ""
hasURL := strings.TrimSpace(c.URL) != ""
switch {
case hasCmd && hasURL:
return fmt.Errorf("un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)")
case !hasCmd && !hasURL:
return fmt.Errorf("un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)")
}
return nil
}
// mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs
// MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle).
var mcpConfigMu sync.Mutex
// LoadMCPConfig lit les serveurs MCP déclarés. Aucun => map vide, pas d'erreur.
func LoadMCPConfig() (map[string]MCPServerConfig, error) {
mcpConfigMu.Lock()
defer mcpConfigMu.Unlock()
return loadMCPConfigLocked()
}
func loadMCPConfigLocked() (map[string]MCPServerConfig, error) {
servers := map[string]MCPServerConfig{}
getJSON(bkState, "mcp", &servers)
if servers == nil {
servers = map[string]MCPServerConfig{}
}
return servers, nil
}
func saveMCPConfigLocked(servers map[string]MCPServerConfig) error {
return putJSON(bkState, "mcp", servers)
}
// SetMCPServer ajoute ou remplace un serveur nommé, puis invalide le pool de
// sessions pour que le changement prenne effet au prochain tour.
func SetMCPServer(name string, cfg MCPServerConfig) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("nom de serveur vide")
}
if strings.Contains(name, "__") {
return fmt.Errorf("le nom ne peut pas contenir '__' (réservé au namespacing des outils)")
}
if err := cfg.Validate(); err != nil {
return err
}
mcpConfigMu.Lock()
servers, err := loadMCPConfigLocked()
if err != nil {
mcpConfigMu.Unlock()
return err
}
servers[name] = cfg
err = saveMCPConfigLocked(servers)
mcpConfigMu.Unlock()
if err != nil {
return err
}
mcpInvalidate(name)
return nil
}
// DeleteMCPServer retire un serveur et ferme sa session si ouverte.
func DeleteMCPServer(name string) error {
mcpConfigMu.Lock()
servers, err := loadMCPConfigLocked()
if err != nil {
mcpConfigMu.Unlock()
return err
}
if _, ok := servers[name]; !ok {
mcpConfigMu.Unlock()
return fmt.Errorf("serveur MCP inconnu: %s", name)
}
delete(servers, name)
err = saveMCPConfigLocked(servers)
mcpConfigMu.Unlock()
if err != nil {
return err
}
mcpInvalidate(name)
return nil
}
// SetMCPServerEnabled active/désactive un serveur existant.
func SetMCPServerEnabled(name string, on bool) error {
mcpConfigMu.Lock()
servers, err := loadMCPConfigLocked()
if err != nil {
mcpConfigMu.Unlock()
return err
}
cfg, ok := servers[name]
if !ok {
mcpConfigMu.Unlock()
return fmt.Errorf("serveur MCP inconnu: %s", name)
}
cfg.Enabled = on
servers[name] = cfg
err = saveMCPConfigLocked(servers)
mcpConfigMu.Unlock()
if err != nil {
return err
}
mcpInvalidate(name)
return nil
}
// SetMCPToolEnabled masque/démasque un outil précis d'un serveur (via sa liste
// DisabledTools), puis invalide la session pour recalculer les outils exposés.
func SetMCPToolEnabled(server, tool string, on bool) error {
mcpConfigMu.Lock()
servers, err := loadMCPConfigLocked()
if err != nil {
mcpConfigMu.Unlock()
return err
}
cfg, ok := servers[server]
if !ok {
mcpConfigMu.Unlock()
return fmt.Errorf("serveur MCP inconnu: %s", server)
}
// Reconstruit la liste sans l'outil concerné, puis l'ajoute si on désactive.
next := cfg.DisabledTools[:0:0]
for _, t := range cfg.DisabledTools {
if t != tool {
next = append(next, t)
}
}
if !on {
next = append(next, tool)
}
cfg.DisabledTools = next
servers[server] = cfg
err = saveMCPConfigLocked(servers)
mcpConfigMu.Unlock()
if err != nil {
return err
}
mcpInvalidate(server)
return nil
}
// sortedServerNames renvoie les noms triés, pour un ordre d'affichage/itération
// stable.
func sortedServerNames(servers map[string]MCPServerConfig) []string {
names := make([]string, 0, len(servers))
for n := range servers {
names = append(names, n)
}
sort.Strings(names)
return names
}