diff --git a/internal/loki/facade_mcp.go b/internal/loki/facade_mcp.go index f451b48..ce3ba36 100644 --- a/internal/loki/facade_mcp.go +++ b/internal/loki/facade_mcp.go @@ -37,11 +37,15 @@ var facadeMCPRoutes = map[string]bool{ "/api/mcp/toggle": true, "/api/mcp/tool": true, "/api/mcp/test": true, + // Les délais globaux : la façade tient le pool, c'est elle qui doit + // fermer ses sessions quand ils changent. + "/api/mcp/timeouts": true, + "/api/mcp/timeouts/save": true, } // facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse. -// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une -// première connexion npx/uvx aussi (mcpConnectTimeoutCold). +// Délai large, simple garde-fou : la façade borne elle-même la connexion +// (1 h au plus) et l'appel, nouvel essai compris (mcpCallTimeoutMax chacun). func facadeMCPJSON(path string, body any, out any) error { b, err := json.Marshal(body) if err != nil { @@ -53,7 +57,7 @@ func facadeMCPJSON(path string, body any, out any) error { } req.Header.Set("Content-Type", "application/json") req.Header.Set(internalTokenHeader, internalToken()) - resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req) + resp, err := (&http.Client{Timeout: mcpTimeoutMaxSec*time.Second + 2*mcpCallTimeoutMax}).Do(req) if err != nil { return err } diff --git a/internal/loki/mcp_call_ctx_test.go b/internal/loki/mcp_call_ctx_test.go index 239e72e..9569845 100644 --- a/internal/loki/mcp_call_ctx_test.go +++ b/internal/loki/mcp_call_ctx_test.go @@ -74,22 +74,27 @@ func TestMCPCallHonorsTurnCancel(t *testing.T) { } func TestMCPCallTimeoutFor(t *testing.T) { + def := MCPTimeouts{}.normalized() cases := []struct { cfg MCPServerConfig + t MCPTimeouts args map[string]any want time.Duration }{ - {MCPServerConfig{}, nil, mcpCallTimeout}, - {MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute}, - {MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes - {MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes - {MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste - {MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne - {MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond - {MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut + {MCPServerConfig{}, def, nil, 120 * time.Second}, + {MCPServerConfig{CallTimeoutSec: 600}, def, nil, 10 * time.Minute}, + {MCPServerConfig{}, def, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes + {MCPServerConfig{}, def, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes + {MCPServerConfig{}, def, map[string]any{"timeout": 5000.0}, 120 * time.Second}, // 5 s : le défaut reste + {MCPServerConfig{}, def, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne + {MCPServerConfig{}, def, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond + {MCPServerConfig{CallTimeoutSec: 30}, def, nil, 30 * time.Second}, // plus court que le défaut + {MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 45}, nil, 45 * time.Second}, // réglage global + {MCPServerConfig{CallTimeoutSec: 90}, MCPTimeouts{Connect: 20, Call: 45}, nil, 90 * time.Second}, + {MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 3600}, nil, time.Hour}, // pas rogné par le plafond } for i, c := range cases { - if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want { + if got := mcpCallTimeoutFor(c.cfg, c.t, c.args); got != c.want { t.Errorf("cas %d : %s, attendu %s", i, got, c.want) } } diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index 8118a9d..6d79709 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -36,19 +36,17 @@ import ( // pour router les appels sans avoir à re-parser des noms potentiellement // ambigus. +// Les délais de connexion et d'appel sont réglables (mcp_timeouts.go) : +// 20 s et 120 s par défaut. const ( - // mcpConnectTimeout borne l'établissement d'une session (handshake initialize - // + tools/list). Un serveur qui rame ne doit pas figer le tour de chat. - mcpConnectTimeout = 20 * time.Second // mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au // premier lancement (le catalogue les utilise tous les deux). Sur un réseau // ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait // « enregistré mais connexion échouée : context deadline exceeded » pour un // serveur parfaitement sain. Les lancements suivants profitent du cache et // retombent dans le régime normal. + // C'est un minimum : un délai de connexion réglé plus long l'emporte. mcpConnectTimeoutCold = 3 * time.Minute - // mcpCallTimeout borne un appel d'outil MCP. - mcpCallTimeout = 120 * time.Second ) // mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont @@ -194,11 +192,12 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession { m.mu.Unlock() // Connexion hors-lock (peut être lente). - ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg)) + timeouts := LoadMCPTimeouts() + ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg, timeouts)) defer cancel() sess, err := mcpConnect(ctx, name, cfg) if err != nil { - s.err = mcpFriendlyErr(err, cfg) + s.err = mcpFriendlyErr(err, cfg, timeouts) } else { s.sess = sess lt, lerr := sess.ListTools(ctx, nil) @@ -391,7 +390,7 @@ func mcpCall(parent context.Context, name string, args map[string]any) string { } servers, _ := LoadMCPConfig() cfg, exists := servers[ref.server] - timeout := mcpCallTimeoutFor(cfg, args) + timeout := mcpCallTimeoutFor(cfg, LoadMCPTimeouts(), args) result, err := mcpCallOnce(parent, ref, args, timeout) if err != nil { @@ -424,14 +423,16 @@ func mcpCall(parent context.Context, name string, args map[string]any) string { // mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil // ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil. -const mcpCallTimeoutMax = 30 * time.Minute +// Il suit la borne des réglages (1 h) : un délai réglé à 3600 s n'est pas +// rogné en silence. +const mcpCallTimeoutMax = mcpTimeoutMaxSec * time.Second -// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par -// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même +// mcpCallTimeoutFor : délai d'un appel. Le réglage global (t.Call), remplacé +// par CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même // (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que // l'outil ait le temps de rendre SA propre erreur de délai. -func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration { - d := mcpCallTimeout +func mcpCallTimeoutFor(cfg MCPServerConfig, t MCPTimeouts, args map[string]any) time.Duration { + d := t.call() if cfg.CallTimeoutSec > 0 { d = time.Duration(cfg.CallTimeoutSec) * time.Second } @@ -598,6 +599,8 @@ type MCPServerStatus struct { Env map[string]string `json:"env,omitempty"` URL string `json:"url,omitempty"` Headers map[string]string `json:"headers,omitempty"` + // CallTimeoutSec : délai d'un appel propre au serveur (0 = réglage global). + CallTimeoutSec int `json:"callTimeoutSec,omitempty"` } // MCPStatus renvoie l'état de tous les serveurs configurés, en tentant de @@ -621,7 +624,9 @@ func MCPStatus() ([]MCPServerStatus, error) { URL: cfg.URL, Headers: cfg.Headers, Tools: []string{}, - Disabled: cfg.DisabledTools, + + CallTimeoutSec: cfg.CallTimeoutSec, + Disabled: cfg.DisabledTools, } if st.Disabled == nil { st.Disabled = []string{} @@ -643,29 +648,36 @@ func MCPStatus() ([]MCPServerStatus, error) { return out, nil } -// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un -// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon. -func mcpTimeoutFor(cfg MCPServerConfig) time.Duration { +// mcpTimeoutFor choisit le délai de connexion d'un serveur : le réglage +// global, mais au moins mcpConnectTimeoutCold pour un lanceur qui télécharge +// son paquet à la volée (npx, uvx). +func mcpTimeoutFor(cfg MCPServerConfig, t MCPTimeouts) time.Duration { + if mcpColdLauncher(cfg) { + return max(t.connect(), mcpConnectTimeoutCold) + } + return t.connect() +} + +// mcpColdLauncher : npx ou uvx, qui téléchargent le paquet au premier +// lancement. +func mcpColdLauncher(cfg MCPServerConfig) bool { base := strings.ToLower(filepath.Base(cfg.Command)) base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd") - if base == "npx" || base == "uvx" { - return mcpConnectTimeoutCold - } - return mcpConnectTimeout + return base == "npx" || base == "uvx" } // mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai // dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context // deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors // qu'il suffit souvent de réessayer une fois le paquet en cache. -func mcpFriendlyErr(err error, cfg MCPServerConfig) error { +func mcpFriendlyErr(err error, cfg MCPServerConfig, t MCPTimeouts) error { if err == nil || !errors.Is(err, context.DeadlineExceeded) { return err } - base := strings.ToLower(filepath.Base(cfg.Command)) - base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd") - if base == "npx" || base == "uvx" { + if mcpColdLauncher(cfg) { + base := strings.ToLower(filepath.Base(cfg.Command)) + base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd") return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base) } - return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout) + return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s (délai de connexion réglable dans les réglages MCP)", t.connect()) } diff --git a/internal/loki/mcp_config.go b/internal/loki/mcp_config.go index 601c9b7..3ba75d4 100644 --- a/internal/loki/mcp_config.go +++ b/internal/loki/mcp_config.go @@ -52,7 +52,7 @@ type MCPServerConfig struct { DisabledTools []string `json:"disabledTools,omitempty"` // CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes. - // 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs + // 0 = réglage global (MCPTimeouts.Call). Pour un serveur dont les outils sont longs // par nature (build, scraping, requête lourde). CallTimeoutSec int `json:"callTimeoutSec,omitempty"` } diff --git a/internal/loki/mcp_timeouts.go b/internal/loki/mcp_timeouts.go new file mode 100644 index 0000000..1f673c6 --- /dev/null +++ b/internal/loki/mcp_timeouts.go @@ -0,0 +1,89 @@ +package loki + +import ( + "time" +) + +// Délais MCP réglables depuis l'UI (repris d'AJEAN c05a0a9f) : connexion d'une +// session et appel d'outil, en secondes, communs à tous les serveurs. Un +// serveur peut remplacer le délai d'appel par le sien (CallTimeoutSec), et un +// lancement npx/uvx garde au moins mcpConnectTimeoutCold pour se connecter. + +// MCPTimeouts : les délais globaux, en secondes. Stockés dans bkState sous +// la clé mcpTimeoutsKey ; zéro (ou clé absente) = valeur par défaut. +type MCPTimeouts struct { + // Connect borne l'établissement d'une session (handshake initialize + + // tools/list). Un serveur qui rame ne doit pas figer le tour de chat. + Connect int `json:"connect"` + // Call borne un appel d'outil MCP. + Call int `json:"call"` +} + +const ( + // Valeurs par défaut, en secondes (les anciennes constantes). + mcpConnectTimeoutDefault = 20 + mcpCallTimeoutDefault = 120 + // mcpTimeoutMaxSec : borne haute des réglages (1 h), globaux comme par + // serveur. + mcpTimeoutMaxSec = 3600 + mcpTimeoutsKey = "mcp_timeouts" +) + +// LoadMCPTimeouts lit les délais globaux. Une valeur absente, nulle ou +// illisible retombe sur son défaut ; une valeur hors bornes (base modifiée à +// la main) est ramenée à mcpTimeoutMaxSec. +func LoadMCPTimeouts() MCPTimeouts { + var t MCPTimeouts + if !getJSON(bkState, mcpTimeoutsKey, &t) { + t = MCPTimeouts{} + } + return t.normalized() +} + +func (t MCPTimeouts) normalized() MCPTimeouts { + fix := func(v, def int) int { + switch { + case v <= 0: + return def + case v > mcpTimeoutMaxSec: + return mcpTimeoutMaxSec + } + return v + } + return MCPTimeouts{ + Connect: fix(t.Connect, mcpConnectTimeoutDefault), + Call: fix(t.Call, mcpCallTimeoutDefault), + } +} + +// SaveMCPTimeouts enregistre les délais globaux (valeurs déjà validées par +// l'appelant ; normalisées par sûreté). +func SaveMCPTimeouts(t MCPTimeouts) error { + mcpConfigMu.Lock() + defer mcpConfigMu.Unlock() + return putJSON(bkState, mcpTimeoutsKey, t.normalized()) +} + +// connect et call : les délais en durées. +func (t MCPTimeouts) connect() time.Duration { return time.Duration(t.Connect) * time.Second } +func (t MCPTimeouts) call() time.Duration { return time.Duration(t.Call) * time.Second } + +// mcpTimeoutsError : le refus d'un réglage hors bornes, nil sinon. Même texte +// pour la façade (mcp/mod.rs). +func mcpTimeoutsError(t MCPTimeouts) string { + if t.Connect < 1 || t.Connect > mcpTimeoutMaxSec { + return "connexion : 1 à 3600 s" + } + if t.Call < 1 || t.Call > mcpTimeoutMaxSec { + return "appel d'outil : 1 à 3600 s" + } + return "" +} + +// mcpCallTimeoutSecError : le refus d'un délai par serveur hors bornes. +func mcpCallTimeoutSecError(sec int) string { + if sec < 0 || sec > mcpTimeoutMaxSec { + return "délai d'un appel : 0 à 3600 s (vide = réglage global)" + } + return "" +} diff --git a/internal/loki/mcp_timeouts_test.go b/internal/loki/mcp_timeouts_test.go new file mode 100644 index 0000000..602755c --- /dev/null +++ b/internal/loki/mcp_timeouts_test.go @@ -0,0 +1,171 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Une base vide donne les délais par défaut : 20 s de connexion, 120 s +// d'appel. +func TestMCPTimeoutsDefautsSansConfig(t *testing.T) { + testHome(t) + got := LoadMCPTimeouts() + if got != (MCPTimeouts{Connect: 20, Call: 120}) { + t.Fatalf("défauts = %+v", got) + } + if d := mcpTimeoutFor(MCPServerConfig{Command: "node"}, got); d != 20*time.Second { + t.Errorf("connexion = %s", d) + } + if d := mcpCallTimeoutFor(MCPServerConfig{}, got, nil); d != 120*time.Second { + t.Errorf("appel = %s", d) + } +} + +// Enregistrés puis relus ; une valeur nulle retombe sur son défaut, une +// valeur trop grande (base modifiée à la main) sur la borne, une valeur +// illisible sur les défauts. +func TestMCPTimeoutsAllerRetour(t *testing.T) { + testHome(t) + if err := SaveMCPTimeouts(MCPTimeouts{Connect: 5, Call: 300}); err != nil { + t.Fatal(err) + } + if got := LoadMCPTimeouts(); got != (MCPTimeouts{Connect: 5, Call: 300}) { + t.Fatalf("relecture = %+v", got) + } + for raw, want := range map[string]MCPTimeouts{ + `{"connect":0,"call":0}`: {20, 120}, + `{"connect":-4,"call":99999}`: {20, 3600}, + `{"connect":"x","call":50}`: {20, 120}, + `{"CONNECT":7}`: {7, 120}, + `null`: {20, 120}, + `{"connect":30,"call":40} reste`: {20, 120}, + } { + if err := putStr(bkState, mcpTimeoutsKey, raw); err != nil { + t.Fatal(err) + } + if got := LoadMCPTimeouts(); got != want { + t.Errorf("%s : %+v, attendu %+v", raw, got, want) + } + } +} + +// Un lanceur npx/uvx garde au moins mcpConnectTimeoutCold ; un délai réglé +// plus long l'emporte. +func TestMCPTimeoutForLanceurFroid(t *testing.T) { + npx := MCPServerConfig{Command: "npx"} + if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 20, Call: 120}); d != mcpConnectTimeoutCold { + t.Errorf("npx, 20 s : %s", d) + } + if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 600, Call: 120}); d != 10*time.Minute { + t.Errorf("npx, 600 s : %s", d) + } + if d := mcpTimeoutFor(MCPServerConfig{URL: "http://x"}, MCPTimeouts{Connect: 45, Call: 120}); d != 45*time.Second { + t.Errorf("http, 45 s : %s", d) + } +} + +func mcpRoute(t *testing.T, h func(w *httptest.ResponseRecorder), want int) map[string]any { + t.Helper() + w := httptest.NewRecorder() + h(w) + var out map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("réponse %q : %v", w.Body.String(), err) + } + if w.Code != want { + t.Fatalf("code %d, attendu %d : %s", w.Code, want, w.Body.String()) + } + return out +} + +// Les routes des délais globaux : lecture, refus hors bornes, enregistrement +// qui ferme les sessions ouvertes. +func TestMCPTimeoutsRoutes(t *testing.T) { + testHome(t) + t.Setenv(facadeMCPEnv, "") + get := func() map[string]any { + return mcpRoute(t, func(w *httptest.ResponseRecorder) { + handleMCPTimeouts(w, httptest.NewRequest("GET", "/api/mcp/timeouts", nil)) + }, 200) + } + post := func(body string, want int) map[string]any { + return mcpRoute(t, func(w *httptest.ResponseRecorder) { + handleMCPTimeoutsSave(w, httptest.NewRequest("POST", "/api/mcp/timeouts/save", strings.NewReader(body))) + }, want) + } + if v := get(); v["connect"] != 20.0 || v["call"] != 120.0 { + t.Fatalf("GET : %v", v) + } + for body, msg := range map[string]string{ + `{"connect":0,"call":120}`: "connexion : 1 à 3600 s", + `{"connect":20,"call":3601}`: "appel d'outil : 1 à 3600 s", + `{"connect":20}`: "appel d'outil : 1 à 3600 s", + `{"connect":"20","call":120}`: "json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int", + } { + if v := post(body, 400); v["error"] != msg { + t.Errorf("%s : %v", body, v["error"]) + } + } + // Une session ouverte sur un serveur configuré : fermée à l'enregistrement. + if err := SetMCPServer("lent", MCPServerConfig{URL: "http://127.0.0.1:1/mcp", Enabled: false}); err != nil { + t.Fatal(err) + } + fakeMCPSlow(t) + if v := post(`{"connect":30,"call":600}`, 200); v["connect"] != 30.0 || v["call"] != 600.0 { + t.Fatalf("POST : %v", v) + } + mcpMgr.mu.Lock() + _, open := mcpMgr.sessions["lent"] + mcpMgr.mu.Unlock() + if open { + t.Error("la session est restée ouverte") + } + if v := get(); v["connect"] != 30.0 || v["call"] != 600.0 { + t.Fatalf("GET après enregistrement : %v", v) + } + if got := getStr(bkState, mcpTimeoutsKey); got != `{"connect":30,"call":600}` { + t.Errorf("stocké : %s", got) + } +} + +// Le délai d'un serveur passe par /api/mcp/save, revient dans /api/mcp et +// survit à un nouvel enregistrement sans lui (0 = réglage global). +func TestMCPSaveCallTimeoutSec(t *testing.T) { + testHome(t) + t.Setenv(facadeMCPEnv, "") + t.Cleanup(mcpCloseAll) + save := func(body string, want int) map[string]any { + return mcpRoute(t, func(w *httptest.ResponseRecorder) { + handleMCPSave(w, httptest.NewRequest("POST", "/api/mcp/save", strings.NewReader(body))) + }, want) + } + v := save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false,"callTimeoutSec":900}`, 200) + srv := v["servers"].([]any)[0].(map[string]any) + if srv["callTimeoutSec"] != 900.0 { + t.Fatalf("état : %v", srv) + } + servers, _ := LoadMCPConfig() + if servers["long"].CallTimeoutSec != 900 { + t.Fatalf("config : %+v", servers["long"]) + } + st, _ := MCPStatus() + if len(st) != 1 || st[0].CallTimeoutSec != 900 { + t.Fatalf("MCPStatus : %+v", st) + } + // Champ vidé dans l'UI : retour au réglage global, clé absente. + v = save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false}`, 200) + if _, ok := v["servers"].([]any)[0].(map[string]any)["callTimeoutSec"]; ok { + t.Fatalf("callTimeoutSec resté : %v", v) + } + for _, body := range []string{ + `{"name":"x","url":"http://h","callTimeoutSec":-1}`, + `{"name":"x","url":"http://h","callTimeoutSec":3601}`, + } { + if v := save(body, 400); v["error"] != "délai d'un appel : 0 à 3600 s (vide = réglage global)" { + t.Errorf("%s : %v", body, v["error"]) + } + } +} diff --git a/internal/loki/parity_mcp_test.go b/internal/loki/parity_mcp_test.go index 0356272..ecdd5bb 100644 --- a/internal/loki/parity_mcp_test.go +++ b/internal/loki/parity_mcp_test.go @@ -91,13 +91,76 @@ func TestPariteMCP(t *testing.T) { } fx["prompt_line"] = lines timeouts := map[string]any{} + def := MCPTimeouts{}.normalized() for _, cmd := range []string{"npx", "/usr/local/bin/NPX", "uvx.exe", "npx.cmd", "node", "", "uvx-x", "C:/bin/uvx.EXE"} { cfg := MCPServerConfig{Command: cmd} - timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()), - "err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()} + timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg, def).Seconds()), + "err": mcpFriendlyErr(context.DeadlineExceeded, cfg, def).Error()} } fx["timeouts"] = timeouts - fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error() + fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}, def).Error() + // Délais globaux (mcp_timeouts) : lecture de la base, puis leur effet sur + // la connexion et sur l'appel. + var stored []any + for _, raw := range []string{"", `{"connect":5,"call":300}`, `{"connect":0,"call":0}`, `{"connect":-4,"call":99999}`, + `{"connect":"x","call":50}`, `{"CONNECT":7,"Call":8}`, `null`, `[1]`, `{"connect":30,"call":40} reste`, + `{"connect":1.5,"call":60}`, `{"connect":3600,"call":3600,"autre":1}`} { + _ = putStr(bkState, mcpTimeoutsKey, raw) + stored = append(stored, map[string]any{"in": raw, "out": LoadMCPTimeouts()}) + } + fx["timeouts_stored"] = stored + var effects []any + for _, tm := range []MCPTimeouts{{20, 120}, {5, 45}, {600, 3600}, {180, 1}} { + for _, c := range []struct { + cfg MCPServerConfig + args string + }{ + {MCPServerConfig{Command: "node"}, `{}`}, + {MCPServerConfig{Command: "npx"}, `{}`}, + {MCPServerConfig{URL: "http://h", CallTimeoutSec: 90}, `{}`}, + {MCPServerConfig{Command: "uvx", CallTimeoutSec: 3600}, `{"timeout":7200}`}, + {MCPServerConfig{URL: "http://h"}, `{"timeout":600}`}, + {MCPServerConfig{URL: "http://h"}, `{"timeout":99999999}`}, + } { + var args map[string]any + _ = json.Unmarshal([]byte(c.args), &args) + cfgJSON, _ := json.Marshal(c.cfg) + effects = append(effects, map[string]any{ + "timeouts": tm, "cfg": string(cfgJSON), "args": c.args, + "connect": mcpTimeoutFor(c.cfg, tm).Milliseconds(), + "err": mcpFriendlyErr(context.DeadlineExceeded, c.cfg, tm).Error(), + "call": mcpCallTimeoutFor(c.cfg, tm, args).Milliseconds(), + }) + } + } + fx["timeouts_effects"] = effects + _ = putStr(bkState, mcpTimeoutsKey, "") + var routes []any + for _, rq := range [][2]string{ + {"/api/mcp/timeouts", ""}, + {"/api/mcp/timeouts/save", `{"connect":0,"call":120}`}, + {"/api/mcp/timeouts/save", `{"connect":20,"call":3601}`}, + {"/api/mcp/timeouts/save", `{"connect":20}`}, + {"/api/mcp/timeouts/save", `{"connect":"20","call":120}`}, + {"/api/mcp/timeouts/save", `{"connect":2.5,"call":120}`}, + {"/api/mcp/timeouts/save", `[]`}, + {"/api/mcp/timeouts/save", `pas du json`}, + {"/api/mcp/timeouts/save", `{"Connect":45,"CALL":900,"x":1}`}, + {"/api/mcp/timeouts", ""}, + {"/api/mcp/timeouts/save", `{"connect":3600,"call":1}`}, + {"/api/mcp/timeouts", ""}, + } { + w := httptest.NewRecorder() + if rq[0] == "/api/mcp/timeouts" { + handleMCPTimeouts(w, httptest.NewRequest("GET", rq[0], nil)) + } else { + handleMCPTimeoutsSave(w, httptest.NewRequest("POST", rq[0], strings.NewReader(rq[1]))) + } + routes = append(routes, map[string]any{"path": rq[0], "body": rq[1], "code": w.Code, + "resp": strings.TrimSpace(w.Body.String()), "state": getStr(bkState, mcpTimeoutsKey)}) + } + fx["timeouts_routes"] = routes + _ = putStr(bkState, mcpTimeoutsKey, "") var flat []any for _, raw := range []string{ `{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`, @@ -148,7 +211,7 @@ func TestPariteMCP(t *testing.T) { {Op: "set", Name: "a__b", Cfg: `{"command":"x"}`}, {Op: "set", Name: "both", Cfg: `{"command":"x","url":"http://u"}`}, {Op: "set", Name: "none", Cfg: `{"enabled":true}`}, - {Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`}, + {Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false,"callTimeoutSec":45}`}, {Op: "enable", Name: "web", On: true}, {Op: "enable", Name: "nope", On: true}, {Op: "tool", Name: "fs", Tool: "write", On: false}, @@ -285,7 +348,7 @@ func TestPariteMCP(t *testing.T) { } _ = SetMCPToolEnabled("alpha", "secret", false) _ = SetMCPServer("beta", MCPServerConfig{Command: "loki-mcp-absent-pour-test", Enabled: true}) - _ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false}) + _ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false, CallTimeoutSec: 300}) raw := getStr(bkState, "mcp") raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1) _ = putStr(bkState, "mcp", raw) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index ab81304..8691060 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2773,6 +2773,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
+ +
+ Délai de connexionpour joindre un serveur ; au moins 3 min au premier lancement npx/uvx + s +
+
+ Délai d'un appel d'outilréglable aussi serveur par serveur + s +
@@ -3292,6 +3303,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+
+ + +