Files
Loki/internal/loki/mcp_config.go
T
Loki de6551a153 Rebaptise AJEAN en Loki (fork, lignée conservée)
- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki)
- LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ;
  units loki-engine / loki-ui ; binaire et aide CLI
- updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les
  binaires AJEAN amont)

Conservé à l'identique : le domaine ajean.link (service de tunnel amont),
les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et
LICENSE (historique et licence de l'amont).

go build/vet/test : verts.
2026-08-14 21:33:15 +00:00

259 lines
7.6 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"`
}
// 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
}