package loki import ( "context" "encoding/json" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "time" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" ) // Pool de sessions MCP + exposition des outils au moteur de chat. // // Une session est gardée ouverte entre les tours de chat : un serveur stdio garde // son process vivant d'un message à l'autre — c'est voulu, desktop-commander & co // ont un coût de démarrage non négligeable. // // Les connexions sont établies EN PARALLÈLE (mcpEnsureAll) : en séquence, quatre // serveurs `npx` faisaient attendre la somme de leurs démarrages (~20 s), là où // les autres clients MCP paient le plus lent. Elles sont aussi pré-chauffées au // démarrage du service web/link (MCPPrewarm) pour que le premier message ne paie // pas le handshake. // // Le namespacing des outils suit la convention de l'écosystème : // mcp____. On maintient un registre nom-exposé → (serveur, outil) // pour router les appels sans avoir à re-parser des noms potentiellement // ambigus. const ( // mcpConnectTimeout borne l'établissement d'une session (handshake initialize // + tools/list). Un serveur qui rame ne doit pas figer le tour de chat. mcpConnectTimeout = 20 * time.Second // mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au // premier lancement (le catalogue les utilise tous les deux). Sur un réseau // ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait // « enregistré mais connexion échouée : context deadline exceeded » pour un // serveur parfaitement sain. Les lancements suivants profitent du cache et // retombent dans le régime normal. mcpConnectTimeoutCold = 3 * time.Minute // mcpCallTimeout borne un appel d'outil MCP. mcpCallTimeout = 120 * time.Second // mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput). mcpMaxOutput = 12000 ) // mcpSession est une connexion vivante à un serveur MCP. // // L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un // second appelant se mette en attente au lieu de lancer un deuxième process. Les // champs sess/tools/err ne doivent donc être lus qu'une fois `ready` fermé. type mcpSession struct { name string cfg MCPServerConfig sess *mcpsdk.ClientSession tools []*mcpsdk.Tool err error // dernière erreur de connexion/liste, pour l'UI ready chan struct{} // fermé quand la tentative de connexion est terminée } // mcpManager détient le pool de sessions. type mcpManager struct { mu sync.Mutex sessions map[string]*mcpSession // registry mappe un nom d'outil exposé au modèle → (serveur, outil réel). registry map[string]mcpToolRef } type mcpToolRef struct { server string tool string } var mcpMgr = &mcpManager{ sessions: map[string]*mcpSession{}, registry: map[string]mcpToolRef{}, } // mcpInvalidate ferme et oublie la session d'un serveur (après un changement de // config), pour qu'elle soit reconstruite avec la nouvelle config au prochain // usage. func mcpInvalidate(name string) { mcpMgr.mu.Lock() s := mcpMgr.sessions[name] delete(mcpMgr.sessions, name) // Purge le registre des outils de ce serveur. for exposed, ref := range mcpMgr.registry { if ref.server == name { delete(mcpMgr.registry, exposed) } } mcpMgr.mu.Unlock() // Une session encore en cours de connexion a sess == nil : c'est sa goroutine // qui refermera ce qu'elle vient d'ouvrir, en constatant qu'elle n'est plus // dans le pool. if s != nil && s.sess != nil { _ = s.sess.Close() } } // mcpCloseAll ferme toutes les sessions (arrêt du service). func mcpCloseAll() { mcpMgr.mu.Lock() sessions := mcpMgr.sessions mcpMgr.sessions = map[string]*mcpSession{} mcpMgr.registry = map[string]mcpToolRef{} mcpMgr.mu.Unlock() for _, s := range sessions { if s.sess != nil { _ = s.sess.Close() } } } // headerRoundTripper injecte des en-têtes statiques (auth) sur chaque requête // HTTP vers un serveur MCP distant. type headerRoundTripper struct { base http.RoundTripper headers map[string]string } func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { for k, v := range h.headers { req.Header.Set(k, v) } return h.base.RoundTrip(req) } // connect établit une session pour la config donnée. func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.ClientSession, error) { client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki", Version: Version}, nil) var transport mcpsdk.Transport switch cfg.Transport() { case "stdio": cmd := hideCmd(exec.Command(cfg.Command, cfg.Args...)) // pas de flash console (mode app Windows) // Hérite de l'environnement du service + surcharges déclarées. cmd.Env = os.Environ() for k, v := range cfg.Env { cmd.Env = append(cmd.Env, k+"="+v) } transport = &mcpsdk.CommandTransport{Command: cmd} case "http": httpClient := &http.Client{} if len(cfg.Headers) > 0 { httpClient.Transport = headerRoundTripper{base: http.DefaultTransport, headers: cfg.Headers} } transport = &mcpsdk.StreamableClientTransport{Endpoint: cfg.URL, HTTPClient: httpClient} default: return nil, fmt.Errorf("serveur MCP '%s' mal configuré (ni command ni url)", name) } sess, err := client.Connect(ctx, transport, nil) if err != nil { return nil, err } return sess, nil } // ensure renvoie une session vivante pour un serveur activé, en la (re)créant si // besoin. Doit être appelé sans détenir mgr.mu (il gère le lock lui-même). // // L'entrée est réservée dans le pool avant la connexion : deux appelants // simultanés (le pré-chauffage et un tour de chat, par exemple) partagent la même // tentative au lieu de lancer deux process stdio. func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession { m.mu.Lock() if s, ok := m.sessions[name]; ok { m.mu.Unlock() <-s.ready // connexion peut-être encore en cours : on attend son issue return s } s := &mcpSession{name: name, cfg: cfg, ready: make(chan struct{})} m.sessions[name] = s m.mu.Unlock() // Connexion hors-lock (peut être lente). ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg)) defer cancel() sess, err := mcpConnect(ctx, name, cfg) if err != nil { s.err = mcpFriendlyErr(err, cfg) } else { s.sess = sess lt, lerr := sess.ListTools(ctx, nil) if lerr != nil { s.err = lerr } else { s.tools = lt.Tools } } m.mu.Lock() // La session a pu être invalidée pendant la connexion (changement de config, // arrêt du service) : dans ce cas on ne la republie pas, on referme. stale := m.sessions[name] != s if !stale { // (Re)peuple le registre pour ce serveur. for exposed, ref := range m.registry { if ref.server == name { delete(m.registry, exposed) } } for _, t := range s.tools { m.registry[mcpExposedName(name, t.Name)] = mcpToolRef{server: name, tool: t.Name} } } m.mu.Unlock() close(s.ready) if stale && s.sess != nil { _ = s.sess.Close() s.sess = nil } return s } // mcpEnsureAll connecte en parallèle tous les serveurs activés et attend qu'ils // soient tous fixés (connectés ou en erreur). C'est ce qui évite d'additionner // les temps de démarrage : le coût total est celui du serveur le plus lent. func mcpEnsureAll(servers map[string]MCPServerConfig) { var wg sync.WaitGroup for _, name := range sortedServerNames(servers) { cfg := servers[name] if !cfg.Enabled || cfg.Transport() == "" { continue } wg.Add(1) go func(name string, cfg MCPServerConfig) { defer wg.Done() mcpMgr.ensure(name, cfg) }(name, cfg) } wg.Wait() } // MCPPrewarm établit les sessions MCP en tâche de fond, au démarrage du service // web/link. Sans ça le handshake était payé par le premier message de chat, ce // qui donnait l'impression d'une IA très lente à démarrer. Sans mode agent les // outils MCP ne sont de toute façon pas exposés : inutile de lancer les process. func MCPPrewarm() { if !agentEnabled() { return } servers, err := LoadMCPConfig() if err != nil || len(servers) == 0 { return } go mcpEnsureAll(servers) } var mcpSanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) // mcpSanitize rend une chaîne compatible avec les noms d'outils (^[A-Za-z0-9_-]+$). func mcpSanitize(s string) string { s = mcpSanitizeRe.ReplaceAllString(s, "_") return strings.Trim(s, "_") } // mcpExposedName construit le nom d'outil vu par le modèle. func mcpExposedName(server, tool string) string { return "mcp__" + mcpSanitize(server) + "__" + mcpSanitize(tool) } // mcpTools connecte les serveurs activés et renvoie leurs outils, prêts à être // annoncés au modèle. Réservé au mode agent (appelé par EnabledTools). func mcpTools() []Tool { servers, err := LoadMCPConfig() if err != nil || len(servers) == 0 { return nil } mcpEnsureAll(servers) // connexions en parallèle, puis lecture du pool (instantanée) var out []Tool for _, name := range sortedServerNames(servers) { cfg := servers[name] if !cfg.Enabled || cfg.Transport() == "" { continue } s := mcpMgr.ensure(name, cfg) if s.sess == nil { continue // connexion échouée : signalé dans l'UI, pas au modèle } for _, t := range s.tools { if cfg.ToolDisabled(t.Name) { continue // outil masqué par l'utilisateur : pas annoncé au modèle } desc := t.Description if desc == "" { desc = t.Title } // Préfixe le serveur d'origine dans la description : aide le modèle à // choisir entre outils similaires de serveurs différents. desc = "[MCP: " + name + "] " + desc out = append(out, Tool{ Type: "function", Function: ToolFunction{ Name: mcpExposedName(name, t.Name), Description: desc, Parameters: mcpNormalizeSchema(t.InputSchema), }, }) } } return out } // mcpNormalizeSchema convertit le InputSchema du SDK (any → map) en map[string]any // pour notre type Tool. Un schéma vide devient un objet sans propriété. func mcpNormalizeSchema(schema any) map[string]any { if m, ok := schema.(map[string]any); ok && m != nil { return m } // Le SDK peut renvoyer une struct/RawMessage : passe par un round-trip JSON. if schema != nil { if b, err := json.Marshal(schema); err == nil { var m map[string]any if json.Unmarshal(b, &m) == nil && m != nil { return m } } } return map[string]any{"type": "object", "properties": map[string]any{}} } // mcpArgLabel produit un libellé court à partir des arguments d'un appel MCP, // pour l'affichage « outil utilisé » dans l'UI (les outils MCP n'ont pas de // champ canonique connu à l'avance). func mcpArgLabel(args map[string]any) string { if len(args) == 0 { return "" } // Privilégie les clés parlantes courantes. for _, k := range []string{"path", "file", "query", "command", "url", "name"} { if v, ok := args[k].(string); ok && v != "" { return v } } b, _ := json.Marshal(args) s := string(b) if r := []rune(s); len(r) > 120 { s = string(r[:120]) + "…" } return s } // isMCPTool indique si un nom d'outil est routé vers MCP. func isMCPTool(name string) bool { return strings.HasPrefix(name, "mcp__") } // mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne // résultat (texte aplati), cappée. Reconnecte une fois si la session est morte. func mcpCall(name string, args map[string]any) string { mcpMgr.mu.Lock() ref, ok := mcpMgr.registry[name] mcpMgr.mu.Unlock() if !ok { return "[erreur] outil MCP inconnu ou serveur non connecté: " + name } result, err := mcpCallOnce(ref, args) if err != nil { // Session peut-être morte (process stdio tombé) : on l'invalide et on // retente une fois avec une reconnexion fraîche. servers, _ := LoadMCPConfig() cfg, exists := servers[ref.server] if exists && cfg.Enabled { mcpInvalidate(ref.server) mcpMgr.ensure(ref.server, cfg) if result2, err2 := mcpCallOnce(ref, args); err2 == nil { return result2 } else { err = err2 } } return "[erreur MCP] " + err.Error() } return result } func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) { mcpMgr.mu.Lock() s := mcpMgr.sessions[ref.server] mcpMgr.mu.Unlock() if s == nil || s.sess == nil { return "", fmt.Errorf("serveur %s non connecté", ref.server) } ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout) defer cancel() res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args}) if err != nil { return "", err } out := flattenMCPContent(res) if r := []rune(out); len(r) > mcpMaxOutput { out = string(r[:mcpMaxOutput]) + "\n…[tronqué]" } if res.IsError { return "[l'outil a renvoyé une erreur]\n" + out, nil } if strings.TrimSpace(out) == "" { return "[ok] (aucune sortie)", nil } return out, nil } // flattenMCPContent aplatit le contenu d'un CallToolResult en texte. Les blocs // texte sont concaténés ; le contenu structuré est rendu en JSON s'il n'y a pas // de texte ; les autres types (image/audio) sont résumés. func flattenMCPContent(res *mcpsdk.CallToolResult) string { var parts []string for _, c := range res.Content { switch v := c.(type) { case *mcpsdk.TextContent: parts = append(parts, v.Text) case *mcpsdk.ImageContent: parts = append(parts, "[image "+v.MIMEType+"]") case *mcpsdk.AudioContent: parts = append(parts, "[audio "+v.MIMEType+"]") default: if b, err := json.Marshal(c); err == nil { parts = append(parts, string(b)) } } } text := strings.TrimSpace(strings.Join(parts, "\n")) if text == "" && res.StructuredContent != nil { if b, err := json.MarshalIndent(res.StructuredContent, "", " "); err == nil { return string(b) } } return text } // mcpPromptLine renvoie une ligne système listant les serveurs MCP connectés et // leur nombre d'outils, pour situer le modèle. Ne force PAS de connexion : lit // seulement l'état déjà établi par mcpTools() (appelé par EnabledTools sur le // même tour), pour ne pas payer deux fois le handshake. func mcpPromptLine() string { mcpMgr.mu.Lock() defer mcpMgr.mu.Unlock() var names []string for name, s := range mcpMgr.sessions { if s.sess != nil && len(s.tools) > 0 { names = append(names, fmt.Sprintf("%s (%d)", name, len(s.tools))) } } if len(names) == 0 { return "" } sort.Strings(names) return "\n\nMCP servers connected (their tools are prefixed mcp____): " + strings.Join(names, ", ") + "." } // MCPServerStatus est l'état d'un serveur pour l'UI web. type MCPServerStatus struct { Name string `json:"name"` Transport string `json:"transport"` Enabled bool `json:"enabled"` Connected bool `json:"connected"` Error string `json:"error,omitempty"` Tools []string `json:"tools"` // tous les outils découverts sur le serveur Disabled []string `json:"disabled"` // sous-ensemble masqué (non exposé à l'IA) // Détail de config (pour pré-remplir le formulaire d'édition). Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` URL string `json:"url,omitempty"` Headers map[string]string `json:"headers,omitempty"` } // MCPStatus renvoie l'état de tous les serveurs configurés, en tentant de // connecter ceux qui sont activés (pour refléter l'état réel dans l'UI). func MCPStatus() ([]MCPServerStatus, error) { servers, err := LoadMCPConfig() if err != nil { return nil, err } mcpEnsureAll(servers) // en parallèle : le panneau MCP de l'UI n'attend plus la somme var out []MCPServerStatus for _, name := range sortedServerNames(servers) { cfg := servers[name] st := MCPServerStatus{ Name: name, Transport: cfg.Transport(), Enabled: cfg.Enabled, Command: cfg.Command, Args: cfg.Args, Env: cfg.Env, URL: cfg.URL, Headers: cfg.Headers, Tools: []string{}, Disabled: cfg.DisabledTools, } if st.Disabled == nil { st.Disabled = []string{} } if cfg.Enabled && cfg.Transport() != "" { s := mcpMgr.ensure(name, cfg) if s.sess != nil { st.Connected = true for _, t := range s.tools { st.Tools = append(st.Tools, t.Name) } } if s.err != nil { st.Error = s.err.Error() } } out = append(out, st) } return out, nil } // mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un // lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon. func mcpTimeoutFor(cfg MCPServerConfig) time.Duration { base := strings.ToLower(filepath.Base(cfg.Command)) base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd") if base == "npx" || base == "uvx" { return mcpConnectTimeoutCold } return mcpConnectTimeout } // mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai // dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context // deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors // qu'il suffit souvent de réessayer une fois le paquet en cache. func mcpFriendlyErr(err error, cfg MCPServerConfig) error { if err == nil || !errors.Is(err, context.DeadlineExceeded) { return err } base := strings.ToLower(filepath.Base(cfg.Command)) base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd") if base == "npx" || base == "uvx" { return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base) } return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout) }