v0.5.0 : intégration MCP (Model Context Protocol)

Jean peut désormais se connecter à des serveurs MCP tiers pour enrichir la
palette d'outils de l'IA (accès fichiers, bases de données, APIs métier…).

- Transports stdio (process local npx/uvx/binaire) et http (Streamable HTTP
  distant, en-têtes d'auth possibles), via le SDK officiel go-sdk.
- Outils namespacés mcp__<serveur>__<outil>, exposés seulement en mode agent
  (même niveau de confiance que le shell : exécution de code sur l'hôte).
- Sessions persistantes (connexion paresseuse + cachée, reconnexion auto).
- Gestion complète dans l'UI web : ajout/édition de serveurs, activation par
  serveur ET par outil (pour maîtriser le contexte), état de connexion en direct.
- Config dans mcp.json (format Claude Desktop mcpServers, copiable).
This commit is contained in:
nathaninline committed 2026-07-24 12:49:56 +02:00
1 parent e8f566ebc0
commit 8e57685b19
19 files changed
+1560 -19

No files matched your search

Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -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": {
+6
View File
@@ -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
+12
View File
@@ -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=
+5
View File
@@ -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()
}
+16 -1
View File
@@ -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 {
+455
View File
@@ -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__<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.
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__<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
}
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
}
+50
View File
@@ -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)
}
}
+284
View File
@@ -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
}
+8 -7
View File
@@ -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") }
+292 -3
View File
@@ -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}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
@@ -405,6 +429,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
</div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
<div class="subhead">Serveurs MCP<span id="mcp-badge" style="margin-left:6px;font-size:10px"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
<div id="param-block" class="gated-block">
<div class="subhead">Paramètres</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
@@ -538,6 +567,65 @@ button:disabled{opacity:.5;cursor:not-allowed}
</div>
</div>
</div>
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong id="mcp-modal-title">Serveur MCP</strong>
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="fld">
<label class="fld-l" for="mcp-name">Nom <span class="opt">(identifiant du serveur, ex: mon-serveur-fichiers)</span></label>
<input id="mcp-name" class="ctl" placeholder="mon-serveur">
</div>
<div class="fld">
<label class="fld-l">Transport</label>
<div class="seg">
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>⚙️ stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>🌐 http (distant)</span></label>
</div>
</div>
<div id="mcp-stdio-fields">
<div class="fld">
<label class="fld-l" for="mcp-command">Commande <span class="opt">(exécutable, ex: npx, uvx, python)</span></label>
<input id="mcp-command" class="ctl" placeholder="npx">
</div>
<div class="fld">
<label class="fld-l" for="mcp-args">Arguments <span class="opt">(un par ligne)</span></label>
<textarea id="mcp-args" class="ctl ta" spellcheck="false" style="min-height:70px" placeholder="-y&#10;@scope/mon-serveur-mcp"></textarea>
</div>
<div class="fld">
<label class="fld-l" for="mcp-env">Variables d'environnement <span class="opt">(KEY=valeur, une par ligne — optionnel)</span></label>
<textarea id="mcp-env" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="API_TOKEN=xxxx"></textarea>
</div>
</div>
<div id="mcp-http-fields" style="display:none">
<div class="fld">
<label class="fld-l" for="mcp-url">URL <span class="opt">(endpoint Streamable HTTP)</span></label>
<input id="mcp-url" class="ctl" placeholder="https://exemple.com/mcp">
</div>
<div class="fld">
<label class="fld-l" for="mcp-headers">En-têtes <span class="opt">(Nom: valeur, un par ligne — ex: Authorization: Bearer …)</span></label>
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
<div id="mcp-tools-list" class="mcp-tools-list"></div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l">
<button id="mcp-del" class="btn-danger" onclick="deleteMcp()" style="display:none">supprimer</button>
</div>
<div class="foot-r">
<button onclick="closeMcp()">annuler</button>
<button class="btn-save" onclick="saveMcp()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
@@ -940,7 +1028,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -1088,7 +1176,7 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
@@ -1489,7 +1577,16 @@ function renderToolMsg(el, tu){
web_read: {lbl:'page web', head:'🌐 lecture'},
web_grep: {lbl:'page web', head:'🌐 recherche'},
};
const meta = META[tu.name] || {lbl:'mémoire', head:'🧠 mémoire'};
// Outils MCP (nom mcp__<serveur>__<outil>) : icône dédiée + libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name;
meta = {lbl: tool, head: '🔌 '+server};
}
meta = meta || {lbl:'mémoire', head:'🧠 mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
@@ -1878,4 +1975,196 @@ function lcToggleLog(){
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
}
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via
// une modale, activation/désactivation, test de connexion. Sous-réglage du mode
// agent (grisé quand l'agent est off, comme mémoire/internet).
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
function renderMCP(r){
mcpServers = (r && r.servers) || [];
const enabled = mcpServers.filter(s=>s.enabled).length;
document.getElementById('mcp-badge').innerHTML = enabled
? '<span class="tag ok">'+enabled+'</span>'
: (mcpServers.length ? '<span class="tag" style="opacity:.6">0</span>' : '');
const list = document.getElementById('mcp-list');
list.textContent = '';
if(!mcpServers.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div>';
return;
}
mcpServers.forEach(s=>{
const row = document.createElement('div');
row.className = 'mcp-row'+(s.enabled?'':' off');
// Pastille d'état : bleu accent = connecté, rouge = erreur, gris = éteint.
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(!s.enabled) dot.classList.add('mcp-dot-off');
else if(s.connected) dot.classList.add('mcp-dot-ok');
else dot.classList.add('mcp-dot-err');
dot.title = !s.enabled ? 'désactivé' : (s.connected ? 'connecté' : 'erreur de connexion');
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = s.name; nm.title = s.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const tr = document.createElement('span'); tr.className = 'mcp-tag';
tr.textContent = s.transport || '?'; meta.appendChild(tr);
if(s.enabled && s.connected){
const total = s.tools ? s.tools.length : 0;
const off = s.disabled ? s.disabled.length : 0;
const active = total - off;
const tc = document.createElement('span'); tc.className = 'mcp-tools';
tc.textContent = off ? (active+'/'+total+' outils') : (total+' outil'+(total>1?'s':''));
if(s.tools && s.tools.length) tc.title = s.tools.join(', ');
meta.appendChild(tc);
} else if(s.enabled && s.error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'erreur'; er.title = s.error;
meta.appendChild(er);
}
info.appendChild(nm); info.appendChild(meta);
// Interrupteur activer/désactiver. stopPropagation sur le label ENTIER (pas
// seulement la checkbox) : sinon un clic sur le slider remonte à la ligne et
// ouvre la modale en même temps que le toggle.
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!s.enabled;
cb.onchange = ()=>toggleMcp(s.name, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openMcp(s.name); };
row.appendChild(dot); row.appendChild(info); row.appendChild(sw); row.appendChild(edit);
row.onclick = ()=>openMcp(s.name);
list.appendChild(row);
});
}
async function toggleMcp(name, on){
renderMCP(await jpost('/api/mcp/toggle', {name, on}));
toast(on ? 'serveur activé' : 'serveur désactivé');
}
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
function openMcp(name){
mcpEditing = name || '';
const s = name ? mcpServers.find(x=>x.name===name) : null;
document.getElementById('mcp-modal-title').textContent = s ? 'Éditer « '+s.name+' »' : 'Nouveau serveur MCP';
document.getElementById('mcp-name').value = s ? s.name : '';
document.getElementById('mcp-name').disabled = !!s; // le nom identifie le serveur, pas de renommage
const transport = s ? (s.transport||'stdio') : 'stdio';
document.querySelector('input[name="mcp-transport"][value="'+transport+'"]').checked = true;
mcpTransportUI(transport);
document.getElementById('mcp-command').value = s ? (s.command||'') : '';
document.getElementById('mcp-args').value = s && s.args ? s.args.join('\n') : '';
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠️ '+s.error; st.style.color = 'var(--err)'; }
else if(s && s.connected){ st.innerHTML = '<span style="color:var(--accent)">✓</span> connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; }
document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s);
document.getElementById('mcp-modal').style.display = 'flex';
}
// Liste des outils découverts du serveur, avec une case par outil pour le
// masquer/exposer à l'IA. Visible seulement pour un serveur connecté.
function renderMcpTools(s){
const sec = document.getElementById('mcp-tools-section');
const list = document.getElementById('mcp-tools-list');
list.textContent = '';
if(!s || !s.connected || !s.tools || !s.tools.length){ sec.style.display = 'none'; return; }
sec.style.display = '';
const off = new Set(s.disabled || []);
document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
s.tools.slice().sort().forEach(tool=>{
const row = document.createElement('label'); row.className = 'mcp-tool-row';
const sw = document.createElement('span'); sw.className = 'switch mcp-tool-switch';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !off.has(tool);
cb.onchange = ()=>toggleMcpTool(s.name, tool, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const nm = document.createElement('code'); nm.className = 'mcp-tool-name'; nm.textContent = tool;
row.appendChild(sw); row.appendChild(nm);
list.appendChild(row);
});
}
async function toggleMcpTool(server, tool, on){
const r = await jpost('/api/mcp/tool', {name: server, tool, on});
renderMCP(r);
// Rafraîchit le compteur de la modale à partir de l'état à jour.
const s = (r.servers||[]).find(x=>x.name===server);
const off = s && s.disabled ? new Set(s.disabled) : new Set();
if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
}
function closeMcp(){ document.getElementById('mcp-modal').style.display = 'none'; mcpEditing = null; }
function mcpTransportUI(t){
document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none';
document.getElementById('mcp-http-fields').style.display = t==='http' ? '' : 'none';
}
// Parse un textarea "KEY<sep>valeur" par ligne → objet. sep = '=' (env) ou ':' (headers).
function mcpParseKV(text, sep){
const out = {};
(text||'').split('\n').forEach(line=>{
const s = line.trim();
if(!s) return;
const i = s.indexOf(sep);
if(i <= 0) return;
out[s.slice(0,i).trim()] = s.slice(i+1).trim();
});
return Object.keys(out).length ? out : undefined;
}
async function saveMcp(){
const name = document.getElementById('mcp-name').value.trim();
if(!name){ toast('donne un nom au serveur'); return; }
const transport = document.querySelector('input[name="mcp-transport"]:checked').value;
const body = {name};
if(transport==='stdio'){
body.command = document.getElementById('mcp-command').value.trim();
if(!body.command){ toast('renseigne la commande'); return; }
body.args = document.getElementById('mcp-args').value.split('\n').map(x=>x.trim()).filter(Boolean);
body.env = mcpParseKV(document.getElementById('mcp-env').value, '=');
body.url = ''; body.headers = undefined;
} else {
body.url = document.getElementById('mcp-url').value.trim();
if(!body.url){ toast("renseigne l'URL"); return; }
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
if(!r.ok){ st.textContent = '⚠️ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name);
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
else if(saved && saved.error){ st.textContent = '⚠️ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else { toast('enregistré'); closeMcp(); }
}
async function deleteMcp(){
if(!mcpEditing) return;
if(!await askConfirm('Retirer le serveur « '+mcpEditing+' » ? Ses outils ne seront plus proposés à l\'IA.', {title:'Supprimer le serveur MCP ?', okText:'Supprimer', danger:true})) return;
await jpost('/api/mcp/delete', {name: mcpEditing});
closeMcp();
loadMCP();
toast('serveur supprimé');
}
</script></body></html>
+64
View File
@@ -69,6 +69,11 @@
</div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
<div class="subhead">Serveurs MCP<span id="mcp-badge" style="margin-left:6px;font-size:10px"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
<div id="param-block" class="gated-block">
<div class="subhead">Paramètres</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
@@ -202,6 +207,65 @@
</div>
</div>
</div>
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong id="mcp-modal-title">Serveur MCP</strong>
<button class="modal-x" onclick="closeMcp()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="fld">
<label class="fld-l" for="mcp-name">Nom <span class="opt">(identifiant du serveur, ex: mon-serveur-fichiers)</span></label>
<input id="mcp-name" class="ctl" placeholder="mon-serveur">
</div>
<div class="fld">
<label class="fld-l">Transport</label>
<div class="seg">
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>⚙️ stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>🌐 http (distant)</span></label>
</div>
</div>
<div id="mcp-stdio-fields">
<div class="fld">
<label class="fld-l" for="mcp-command">Commande <span class="opt">(exécutable, ex: npx, uvx, python)</span></label>
<input id="mcp-command" class="ctl" placeholder="npx">
</div>
<div class="fld">
<label class="fld-l" for="mcp-args">Arguments <span class="opt">(un par ligne)</span></label>
<textarea id="mcp-args" class="ctl ta" spellcheck="false" style="min-height:70px" placeholder="-y&#10;@scope/mon-serveur-mcp"></textarea>
</div>
<div class="fld">
<label class="fld-l" for="mcp-env">Variables d'environnement <span class="opt">(KEY=valeur, une par ligne — optionnel)</span></label>
<textarea id="mcp-env" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="API_TOKEN=xxxx"></textarea>
</div>
</div>
<div id="mcp-http-fields" style="display:none">
<div class="fld">
<label class="fld-l" for="mcp-url">URL <span class="opt">(endpoint Streamable HTTP)</span></label>
<input id="mcp-url" class="ctl" placeholder="https://exemple.com/mcp">
</div>
<div class="fld">
<label class="fld-l" for="mcp-headers">En-têtes <span class="opt">(Nom: valeur, un par ligne — ex: Authorization: Bearer …)</span></label>
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
<div id="mcp-tools-list" class="mcp-tools-list"></div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l">
<button id="mcp-del" class="btn-danger" onclick="deleteMcp()" style="display:none">supprimer</button>
</div>
<div class="foot-r">
<button onclick="closeMcp()">annuler</button>
<button class="btn-save" onclick="saveMcp()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
+2 -2
View File
@@ -68,7 +68,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -216,5 +216,5 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+10 -1
View File
@@ -107,7 +107,16 @@ function renderToolMsg(el, tu){
web_read: {lbl:'page web', head:'🌐 lecture'},
web_grep: {lbl:'page web', head:'🌐 recherche'},
};
const meta = META[tu.name] || {lbl:'mémoire', head:'🧠 mémoire'};
// Outils MCP (nom mcp__<serveur>__<outil>) : icône dédiée + libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name;
meta = {lbl: tool, head: '🔌 '+server};
}
meta = meta || {lbl:'mémoire', head:'🧠 mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
+192
View File
@@ -0,0 +1,192 @@
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via
// une modale, activation/désactivation, test de connexion. Sous-réglage du mode
// agent (grisé quand l'agent est off, comme mémoire/internet).
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
function renderMCP(r){
mcpServers = (r && r.servers) || [];
const enabled = mcpServers.filter(s=>s.enabled).length;
document.getElementById('mcp-badge').innerHTML = enabled
? '<span class="tag ok">'+enabled+'</span>'
: (mcpServers.length ? '<span class="tag" style="opacity:.6">0</span>' : '');
const list = document.getElementById('mcp-list');
list.textContent = '';
if(!mcpServers.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div>';
return;
}
mcpServers.forEach(s=>{
const row = document.createElement('div');
row.className = 'mcp-row'+(s.enabled?'':' off');
// Pastille d'état : bleu accent = connecté, rouge = erreur, gris = éteint.
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(!s.enabled) dot.classList.add('mcp-dot-off');
else if(s.connected) dot.classList.add('mcp-dot-ok');
else dot.classList.add('mcp-dot-err');
dot.title = !s.enabled ? 'désactivé' : (s.connected ? 'connecté' : 'erreur de connexion');
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = s.name; nm.title = s.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const tr = document.createElement('span'); tr.className = 'mcp-tag';
tr.textContent = s.transport || '?'; meta.appendChild(tr);
if(s.enabled && s.connected){
const total = s.tools ? s.tools.length : 0;
const off = s.disabled ? s.disabled.length : 0;
const active = total - off;
const tc = document.createElement('span'); tc.className = 'mcp-tools';
tc.textContent = off ? (active+'/'+total+' outils') : (total+' outil'+(total>1?'s':''));
if(s.tools && s.tools.length) tc.title = s.tools.join(', ');
meta.appendChild(tc);
} else if(s.enabled && s.error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'erreur'; er.title = s.error;
meta.appendChild(er);
}
info.appendChild(nm); info.appendChild(meta);
// Interrupteur activer/désactiver. stopPropagation sur le label ENTIER (pas
// seulement la checkbox) : sinon un clic sur le slider remonte à la ligne et
// ouvre la modale en même temps que le toggle.
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!s.enabled;
cb.onchange = ()=>toggleMcp(s.name, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openMcp(s.name); };
row.appendChild(dot); row.appendChild(info); row.appendChild(sw); row.appendChild(edit);
row.onclick = ()=>openMcp(s.name);
list.appendChild(row);
});
}
async function toggleMcp(name, on){
renderMCP(await jpost('/api/mcp/toggle', {name, on}));
toast(on ? 'serveur activé' : 'serveur désactivé');
}
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
function openMcp(name){
mcpEditing = name || '';
const s = name ? mcpServers.find(x=>x.name===name) : null;
document.getElementById('mcp-modal-title').textContent = s ? 'Éditer « '+s.name+' »' : 'Nouveau serveur MCP';
document.getElementById('mcp-name').value = s ? s.name : '';
document.getElementById('mcp-name').disabled = !!s; // le nom identifie le serveur, pas de renommage
const transport = s ? (s.transport||'stdio') : 'stdio';
document.querySelector('input[name="mcp-transport"][value="'+transport+'"]').checked = true;
mcpTransportUI(transport);
document.getElementById('mcp-command').value = s ? (s.command||'') : '';
document.getElementById('mcp-args').value = s && s.args ? s.args.join('\n') : '';
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠️ '+s.error; st.style.color = 'var(--err)'; }
else if(s && s.connected){ st.innerHTML = '<span style="color:var(--accent)">✓</span> connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; }
document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s);
document.getElementById('mcp-modal').style.display = 'flex';
}
// Liste des outils découverts du serveur, avec une case par outil pour le
// masquer/exposer à l'IA. Visible seulement pour un serveur connecté.
function renderMcpTools(s){
const sec = document.getElementById('mcp-tools-section');
const list = document.getElementById('mcp-tools-list');
list.textContent = '';
if(!s || !s.connected || !s.tools || !s.tools.length){ sec.style.display = 'none'; return; }
sec.style.display = '';
const off = new Set(s.disabled || []);
document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
s.tools.slice().sort().forEach(tool=>{
const row = document.createElement('label'); row.className = 'mcp-tool-row';
const sw = document.createElement('span'); sw.className = 'switch mcp-tool-switch';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !off.has(tool);
cb.onchange = ()=>toggleMcpTool(s.name, tool, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const nm = document.createElement('code'); nm.className = 'mcp-tool-name'; nm.textContent = tool;
row.appendChild(sw); row.appendChild(nm);
list.appendChild(row);
});
}
async function toggleMcpTool(server, tool, on){
const r = await jpost('/api/mcp/tool', {name: server, tool, on});
renderMCP(r);
// Rafraîchit le compteur de la modale à partir de l'état à jour.
const s = (r.servers||[]).find(x=>x.name===server);
const off = s && s.disabled ? new Set(s.disabled) : new Set();
if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)';
}
function closeMcp(){ document.getElementById('mcp-modal').style.display = 'none'; mcpEditing = null; }
function mcpTransportUI(t){
document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none';
document.getElementById('mcp-http-fields').style.display = t==='http' ? '' : 'none';
}
// Parse un textarea "KEY<sep>valeur" par ligne → objet. sep = '=' (env) ou ':' (headers).
function mcpParseKV(text, sep){
const out = {};
(text||'').split('\n').forEach(line=>{
const s = line.trim();
if(!s) return;
const i = s.indexOf(sep);
if(i <= 0) return;
out[s.slice(0,i).trim()] = s.slice(i+1).trim();
});
return Object.keys(out).length ? out : undefined;
}
async function saveMcp(){
const name = document.getElementById('mcp-name').value.trim();
if(!name){ toast('donne un nom au serveur'); return; }
const transport = document.querySelector('input[name="mcp-transport"]:checked').value;
const body = {name};
if(transport==='stdio'){
body.command = document.getElementById('mcp-command').value.trim();
if(!body.command){ toast('renseigne la commande'); return; }
body.args = document.getElementById('mcp-args').value.split('\n').map(x=>x.trim()).filter(Boolean);
body.env = mcpParseKV(document.getElementById('mcp-env').value, '=');
body.url = ''; body.headers = undefined;
} else {
body.url = document.getElementById('mcp-url').value.trim();
if(!body.url){ toast("renseigne l'URL"); return; }
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
if(!r.ok){ st.textContent = '⚠️ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name);
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
else if(saved && saved.error){ st.textContent = '⚠️ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else { toast('enregistré'); closeMcp(); }
}
async function deleteMcp(){
if(!mcpEditing) return;
if(!await askConfirm('Retirer le serveur « '+mcpEditing+' » ? Ses outils ne seront plus proposés à l\'IA.', {title:'Supprimer le serveur MCP ?', okText:'Supprimer', danger:true})) return;
await jpost('/api/mcp/delete', {name: mcpEditing});
closeMcp();
loadMCP();
toast('serveur supprimé');
}
+24
View File
@@ -335,3 +335,27 @@ 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}
+129
View File
@@ -483,6 +483,135 @@ func handleInternet(w http.ResponseWriter, r *http.Request) {
})
}
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
//
// Gestion des serveurs MCP depuis l'UI. Même modèle de protection que le reste
// de /api/* : la clé web est le seul garde. Un serveur MCP stdio exécute du code
// arbitraire sur l'hôte, au même titre que l'outil `bash` du mode agent — donc
// réservé de fait au propriétaire de la machine qui détient la clé.
// mcpSaveReq est le payload d'ajout/édition d'un serveur MCP.
type mcpSaveReq struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
}
// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée
// pour ceux activés → l'UI voit l'état réel + les outils découverts).
func handleMCP(w http.ResponseWriter, r *http.Request) {
list, err := MCPStatus()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPSave ajoute ou remplace un serveur MCP.
func handleMCPSave(w http.ResponseWriter, r *http.Request) {
var req mcpSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
cfg := MCPServerConfig{
Command: strings.TrimSpace(req.Command),
Args: req.Args,
Env: req.Env,
URL: strings.TrimSpace(req.URL),
Headers: req.Headers,
Enabled: enabled,
}
if err := SetMCPServer(req.Name, cfg); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// Renvoie l'état à jour (avec tentative de connexion) pour un retour immédiat.
list, _ := MCPStatus()
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPDelete retire un serveur MCP.
func handleMCPDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := DeleteMCPServer(req.Name); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleMCPToggle active/désactive un serveur MCP.
func handleMCPToggle(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
On bool `json:"on"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := SetMCPServerEnabled(req.Name, req.On); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
list, _ := MCPStatus()
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPTool masque/démasque un outil précis d'un serveur.
func handleMCPTool(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Tool string `json:"tool"`
On bool `json:"on"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := SetMCPToolEnabled(req.Name, req.Tool, req.On); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
list, _ := MCPStatus()
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPTest force une reconnexion d'un serveur et renvoie son état (bouton
// « tester/rafraîchir » de l'UI).
func handleMCPTest(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
mcpInvalidate(req.Name)
list, err := MCPStatus()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
// (MEMORY/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
// de fichier de la page.
+6
View File
@@ -108,6 +108,12 @@ func newWebMux() *http.ServeMux {
api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic)
api("/api/internet", handleInternet)
api("/api/mcp", handleMCP)
api("/api/mcp/save", handleMCPSave)
api("/api/mcp/delete", handleMCPDelete)
api("/api/mcp/toggle", handleMCPToggle)
api("/api/mcp/tool", handleMCPTool)
api("/api/mcp/test", handleMCPTest)
api("/api/memory", handleMemoryMode)
api("/api/prefs", handleWebPrefs)
api("/api/sysprompt", handleSysPrompt)