mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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:
1 parent
e8f566ebc0
commit
8e57685b19
19 files changed
+1560
-19
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 @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>
|
||||
@@ -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 @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">
|
||||
|
||||
@@ -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); }
|
||||
@@ -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'; }
|
||||
|
||||
@@ -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é');
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user