mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
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.
This commit is contained in:
157 files changed
+928
-928
No files matched your search
@@ -0,0 +1,511 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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__<serveur>__<outil>. 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
|
||||
// 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(), mcpConnectTimeout)
|
||||
defer cancel()
|
||||
sess, err := mcpConnect(ctx, name, cfg)
|
||||
if err != nil {
|
||||
s.err = err
|
||||
} 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__<server>__<tool>): " + 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
|
||||
}
|
||||
Reference in new issue
Block a user