diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 921cd1b..26db6bb 100644 Binary files a/cmd/jean/resource_windows_amd64.syso and b/cmd/jean/resource_windows_amd64.syso differ diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 4598dde..0fd37e6 100644 Binary files a/cmd/jean/resource_windows_arm64.syso and b/cmd/jean/resource_windows_arm64.syso differ diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 91be45b..53e5ea5 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -2,14 +2,14 @@ "FixedFileInfo": { "FileVersion": { "Major": 0, - "Minor": 4, - "Patch": 16, + "Minor": 5, + "Patch": 0, "Build": 0 }, "ProductVersion": { "Major": 0, - "Minor": 4, - "Patch": 16, + "Minor": 5, + "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.4.16", + "ProductVersion": "0.5.0", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/go.mod b/go.mod index dedb52b..5e588df 100644 --- a/go.mod +++ b/go.mod @@ -19,17 +19,23 @@ require ( github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect github.com/miekg/dns v1.1.72 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/go.sum b/go.sum index 8769d1f..d5652f5 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -45,15 +47,23 @@ github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -74,6 +84,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/jean/chat_tools.go b/internal/jean/chat_tools.go index f99d381..ba59256 100644 --- a/internal/jean/chat_tools.go +++ b/internal/jean/chat_tools.go @@ -72,6 +72,11 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, fixtures, 'since when') call web_search BEFORE writing any date or version. Your answer must match the dates/facts you actually read.\n") b.WriteString("SEARCH QUERY YEAR RULE: today is in " + year + ". If your query includes a year, use ONLY " + year + " — NEVER write a past year like " + prevYear(year) + " that you remember from training; it silently biases results toward stale pages. Default: put no year at all and let the freshest result win. Don't hedge ('probably', 'I think') about a fact a tool can verify — search instead.\n") } + if caps.Agent { + if line := mcpPromptLine(); line != "" { + b.WriteString(line) + } + } b.WriteString("\nDate: " + time.Now().Format("2006-01-02")) return b.String() } diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go index facfbac..88bd1aa 100644 --- a/internal/jean/llm_client.go +++ b/internal/jean/llm_client.go @@ -235,6 +235,12 @@ func EnabledTools(caps Caps) []Tool { if caps.Agent && caps.Internet { tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool()) } + // Outils MCP : serveurs tiers configurés par le propriétaire de la machine. + // Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode + // agent. La découverte est paresseuse et cachée (voir mcp_client.go). + if caps.Agent { + tools = append(tools, mcpTools()...) + } return tools } @@ -724,6 +730,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps u, _ := args["url"].(string) p, _ := args["pattern"].(string) label = p + " @ " + u + default: + // Outils MCP : libellé = un aperçu compact des arguments. + if isMCPTool(tc.Function.Name) { + label = mcpArgLabel(args) + } } cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) @@ -794,7 +805,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps case "web_grep": result = toolWebGrep(args) default: - result = "[erreur] outil inconnu: " + tc.Function.Name + if isMCPTool(tc.Function.Name) { + result = mcpCall(tc.Function.Name, args) + } else { + result = "[erreur] outil inconnu: " + tc.Function.Name + } } shown := result if r := []rune(shown); len(r) > 4000 { diff --git a/internal/jean/mcp_client.go b/internal/jean/mcp_client.go new file mode 100644 index 0000000..c71163f --- /dev/null +++ b/internal/jean/mcp_client.go @@ -0,0 +1,455 @@ +package jean + +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 établie paresseusement au premier besoin (premier tour de chat +// avec le mode agent actif) et gardée ouverte pour les tours suivants. Un serveur +// stdio garde donc son process vivant entre les messages — c'est voulu : +// desktop-commander & co ont un coût de démarrage non négligeable. +// +// 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 + // 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. +type mcpSession struct { + name string + cfg MCPServerConfig + sess *mcpsdk.ClientSession + tools []*mcpsdk.Tool + err error // dernière erreur de connexion/liste, pour l'UI +} + +// 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() + 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: "jean", Version: Version}, nil) + + var transport mcpsdk.Transport + switch cfg.Transport() { + case "stdio": + cmd := exec.Command(cfg.Command, cfg.Args...) + // 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). +func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession { + m.mu.Lock() + if s, ok := m.sessions[name]; ok { + m.mu.Unlock() + return s + } + m.mu.Unlock() + + // Connexion hors-lock (peut être lente). + s := &mcpSession{name: name, cfg: cfg} + 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() + // Un autre appelant a pu connecter entretemps : garder la première. + if existing, ok := m.sessions[name]; ok { + m.mu.Unlock() + if s.sess != nil { + _ = s.sess.Close() + } + return existing + } + m.sessions[name] = s + // (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() + return s +} + +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 + } + 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 + } + 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 +} diff --git a/internal/jean/mcp_client_test.go b/internal/jean/mcp_client_test.go new file mode 100644 index 0000000..cac8f78 --- /dev/null +++ b/internal/jean/mcp_client_test.go @@ -0,0 +1,50 @@ +package jean + +import ( + "os/exec" + "strings" + "testing" +) + +// TestMCPEndToEnd connecte le serveur MCP de référence (@modelcontextprotocol/ +// server-everything via npx), vérifie la découverte d'outils namespacés puis un +// appel réel (echo). Skippé si npx est absent (CI sans Node). +func TestMCPEndToEnd(t *testing.T) { + if _, err := exec.LookPath("npx"); err != nil { + t.Skip("npx indisponible — test MCP e2e ignoré") + } + t.Setenv("JEAN_HOME", t.TempDir()) + + cfg := MCPServerConfig{ + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-everything"}, + Enabled: true, + } + if err := SetMCPServer("everything", cfg); err != nil { + t.Fatalf("SetMCPServer: %v", err) + } + t.Cleanup(mcpCloseAll) + + tools := mcpTools() + if len(tools) == 0 { + t.Fatal("aucun outil MCP découvert (le serveur s'est-il connecté ?)") + } + echoName := mcpExposedName("everything", "echo") + found := false + for _, tl := range tools { + if tl.Function.Name == echoName { + found = true + } + if !strings.HasPrefix(tl.Function.Name, "mcp__everything__") { + t.Errorf("outil mal namespacé: %s", tl.Function.Name) + } + } + if !found { + t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools)) + } + + out := mcpCall(echoName, map[string]any{"message": "bonjour-jean"}) + if !strings.Contains(out, "bonjour-jean") { + t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out) + } +} diff --git a/internal/jean/mcp_config.go b/internal/jean/mcp_config.go new file mode 100644 index 0000000..0dca871 --- /dev/null +++ b/internal/jean/mcp_config.go @@ -0,0 +1,284 @@ +package jean + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" +) + +// Configuration des serveurs MCP (Model Context Protocol). +// +// jean 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 : jean 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 : jean 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 jean — 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 +} + +// mcpConfigFile est la représentation sur disque de mcp.json. +type mcpConfigFile struct { + MCPServers map[string]MCPServerConfig `json:"mcpServers"` +} + +// mcpConfigMu sérialise les accès concurrents au fichier mcp.json (l'UI web et +// les tours de chat peuvent lire/écrire en parallèle). +var mcpConfigMu sync.Mutex + +// LoadMCPConfig lit mcp.json. Fichier absent => config vide (pas d'erreur). +func LoadMCPConfig() (map[string]MCPServerConfig, error) { + mcpConfigMu.Lock() + defer mcpConfigMu.Unlock() + return loadMCPConfigLocked() +} + +func loadMCPConfigLocked() (map[string]MCPServerConfig, error) { + b, err := os.ReadFile(mcpConfigPath()) + if err != nil { + if os.IsNotExist(err) { + return map[string]MCPServerConfig{}, nil + } + return nil, err + } + var f mcpConfigFile + if err := json.Unmarshal(b, &f); err != nil { + return nil, fmt.Errorf("mcp.json invalide: %w", err) + } + if f.MCPServers == nil { + f.MCPServers = map[string]MCPServerConfig{} + } + return f.MCPServers, nil +} + +// saveMCPConfigLocked écrit atomiquement mcp.json (write temp + rename). +func saveMCPConfigLocked(servers map[string]MCPServerConfig) error { + _ = os.MkdirAll(JeanHome(), 0o755) + f := mcpConfigFile{MCPServers: servers} + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + tmp := mcpConfigPath() + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, mcpConfigPath()) +} + +// 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 +} diff --git a/internal/jean/run.go b/internal/jean/run.go index c8a2901..4ba00a0 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.16" +const Version = "0.5.0" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { @@ -201,12 +201,13 @@ func readEtcDefault() string { return "" } -func confPath() string { return filepath.Join(JeanHome(), "config.env") } -func presetsDir() string { return filepath.Join(JeanHome(), "configs") } -func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } -func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") } -func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") } -func internetFlag() string { return filepath.Join(JeanHome(), ".internet_enabled") } +func confPath() string { return filepath.Join(JeanHome(), "config.env") } +func presetsDir() string { return filepath.Join(JeanHome(), "configs") } +func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } +func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") } +func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") } +func internetFlag() string { return filepath.Join(JeanHome(), ".internet_enabled") } +func mcpConfigPath() string { return filepath.Join(JeanHome(), "mcp.json") } // Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié. func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 32f80ca..e4bbe28 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -354,6 +354,30 @@ button:disabled{opacity:.5;cursor:not-allowed} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} #lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} .lc-spin{display:inline-block;animation:spin 1s linear infinite} +/* Serveurs MCP */ +.mcp-list{display:flex;flex-direction:column;gap:4px} +.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel);cursor:pointer} +.mcp-row:hover{border-color:var(--accent)} +.mcp-row.off{opacity:.6} +.mcp-dot{flex:none;width:8px;height:8px;border-radius:50%} +.mcp-dot-ok{background:var(--accent)} +.mcp-dot-err{background:var(--err)} +.mcp-dot-off{background:var(--dim)} +.mcp-info{flex:1;min-width:0} +.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px} +.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em} +.mcp-tools{font-size:10px;color:var(--accent)} +.mcp-err{font-size:10px;color:var(--err)} +.mcp-switch{flex:none} +.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5} +.mcp-edit:hover{opacity:1;color:var(--accent)} +/* Liste des outils MCP dans la modale */ +.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px} +.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer} +.mcp-tool-row:hover{background:var(--panel)} +.mcp-tool-switch{flex:none;transform:scale(.85)} +.mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all}
@@ -405,6 +429,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
+
+
Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
+
+ +
Paramètres
@@ -538,6 +567,65 @@ button:disabled{opacity:.5;cursor:not-allowed}
+ +