Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 d5140f3b3a Banc d'essai : journal régénéré après le changement de l'UI
Le banc coupe son corpus au milieu de l'index.html embarqué : les deux
lignes de la carte « Serveurs MCP » et le champ de la fenêtre d'édition le
déplacent. Journal tiré sous Linux (LOKI_PARITY_UPDATE=1, TestPariteBench).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:24:27 +02:00
MichaelandClaude Opus 5.5 f4026506ff Façade : délais MCP globaux et délai par serveur, comme le Go
La façade lit mcp_timeouts pour se connecter (au moins 3 min pour npx/uvx)
et pour chaque appel, sert GET /api/mcp/timeouts et
POST /api/mcp/timeouts/save avec les mêmes refus que le Go, et ferme ses
sessions à l'enregistrement. /api/mcp/save accepte callTimeoutSec (0 à
3600) au lieu de le remettre à zéro, et /api/mcp le renvoie. Plafond d'un
appel porté à 1 h. tests/mcp.rs rejoue le journal tiré du Go : lecture des
délais, effets, routes, sessions fermées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:30 +02:00
MichaelandClaude Opus 5.5 2e28ca955e MCP : délais de connexion et d'appel réglables dans l'interface
Repris d'AJEAN c05a0a9f et e44b6139. Les deux délais MCP, jusqu'ici codés en
dur (20 s pour se connecter, 120 s pour un appel d'outil), deviennent des
réglages globaux : clé bkState mcp_timeouts, bornes 1 à 3600 s, routes
GET /api/mcp/timeouts et POST /api/mcp/timeouts/save. L'enregistrement ferme
les sessions ouvertes pour que le nouveau délai de connexion s'applique dès
la suivante. Un lancement npx/uvx garde au moins mcpConnectTimeoutCold.

Un appel prend le réglage global, toujours remplacé par le callTimeoutSec du
serveur, puis allongé par l'argument timeout de l'outil. Le plafond d'un
appel suit la borne des réglages (1 h au lieu de 30 min) : un délai réglé à
3600 s n'est plus rogné en silence. Une valeur rangée hors bornes ou
illisible retombe sur la borne ou le défaut.

Le délai par serveur passe par /api/mcp/save (callTimeoutSec, 0 à 3600,
vide = réglage global), revient dans /api/mcp et n'est plus perdu à
l'enregistrement. L'interface gagne deux lignes dans la carte « Serveurs
MCP », au style des réglages (sans bordure, un seul enregistrement par
modification), et un champ « délai d'un appel d'outil » dans la fenêtre
d'édition d'un serveur. index.html régénéré par tools/assemble-ui.

Quand la façade tient les serveurs MCP (LOKI_MCP=rust), elle sert aussi
les nouvelles routes : c'est son pool qu'il faut fermer quand les délais
changent. Le journal de parité MCP couvre la lecture des délais, leur effet
sur la connexion et l'appel, et les deux routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:14 +02:00
LogiFlow 6a45f19831 Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox
Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
2026-10-19 11:22:20 +02:00
MichaelandClaude Opus 5.5 4c2cf8b4e6 Façade : délai d'appel MCP par serveur et par argument, pas de relance au délai dépassé
Même règle que le Go (mcpCallTimeoutFor) : 120 s par défaut, remplacés par
callTimeoutSec du serveur, allongés par l'argument timeout de l'outil (+30 s,
millisecondes au-delà de 3600), plafond 30 min. Un délai dépassé rend la même
erreur que le Go sans reconnecter ni relancer l'outil. callTimeoutSec est lu
et réécrit tel quel dans la configuration des serveurs : un enregistrement
par la façade ne le perd plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 68dd65cd5a Façade : motifs de relance vides écartés, comme le Go
Un motif vide (ou blanc) dans retry_patterns donnait (?mi), qui correspond à
tout : chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
motif utilisable, la façade retombe sur les motifs embarqués, comme
compileRetryPatterns côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
35 changed files with 2573 additions and 294 deletions

No files matched your search

+56 -6
View File
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
}
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
// chaque étape suivante relançait un résumé complet voué au même échec : plus
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
// encore vaut mieux que déborder.
func compactRetryDue(retryAt, used int, msgs []Message) bool {
if retryAt <= 0 {
return true
}
est := compactUsedOrEstimate(used, msgs)
return est >= retryAt || est*10 >= ctxWindow()*9
}
// compactNextRetry : seuil du prochain essai après une compaction en cours de
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
// de la fenêtre.
func compactNextRetry(changed bool, used int, msgs []Message) int {
if changed {
return 0
}
est := compactUsedOrEstimate(used, msgs)
next := est + ctxWindow()/20
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
return next
}
func compactUsedOrEstimate(used int, msgs []Message) int {
if used > 0 {
return used
}
return estimateTokens(msgs)
}
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
var blocks []recallArchiveInput
var at []int // position dans le torse de chaque bloc
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
at = append(at, i)
}
// Une seule transaction pour tous les blocs (AJEAN #130).
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
for k, e := range entries {
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
index = append(index, e)
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
} else if len(blocks) > 0 {
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
}
}
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
} else {
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
// 68659e73).
if err != nil {
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
} else if summaryLooksEmpty(summary) {
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
}
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
+32
View File
@@ -0,0 +1,32 @@
package loki
import "testing"
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
// fenêtre — sauf à 90 % de la fenêtre ou plus.
func TestCompactRetryBackoff(t *testing.T) {
testHome(t)
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
t.Fatal(err)
}
if !compactRetryDue(0, 70000, nil) {
t.Fatal("sans échec précédent, l'essai doit être permis")
}
next := compactNextRetry(false, 70000, nil)
if next != 75000 {
t.Fatalf("prochain essai à %d, attendu 75000", next)
}
if compactRetryDue(next, 72000, nil) {
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
}
if !compactRetryDue(next, 75000, nil) {
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
}
if !compactRetryDue(200000, 90000, nil) {
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
}
if compactNextRetry(true, 70000, nil) != 0 {
t.Fatal("une compaction réussie doit lever le garde-fou")
}
}
+65 -35
View File
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
type recallArchiveInput struct {
Label string
Role string
Content string
}
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
// sont alloués par NextSequence dans la transaction : monotones, sans
// collision, même si deux compactages se croisent. Une transaction par bloc
// payait une ouverture de la base et une synchronisation disque chacune, sous
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
// classique (troncature sans id).
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
if len(blocks) == 0 {
return nil, nil
}
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
encode, err := memEncoderNow()
if err != nil {
return "", err
return nil, err
}
var id string
now := time.Now().Unix()
var entries []recallEntry
err = withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
entries = entries[:0]
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
for _, in := range blocks {
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: in.Label,
Role: in.Role,
Content: in.Content,
Created: now,
}
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
// inchangé si le chiffrement est inactif ; verrouillé,
// memEncoderNow a déjà refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
if err := b.Put(recallKey(seq), enc); err != nil {
return err
}
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
// refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put(recallKey(seq), enc)
return nil
})
})
if err != nil {
return "", err
return nil, err
}
cacheBust(bkRecall)
return id, nil
return entries, nil
}
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
func archiveRecallBlock(label, role, content string) (string, error) {
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
if err != nil {
return "", err
}
return e[0].id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
+44
View File
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
func TestArchiveRecallBlocksBatch(t *testing.T) {
testHome(t)
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
t.Fatalf("lot vide : %v, %v", e, err)
}
in := []recallArchiveInput{
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
}
entries, err := archiveRecallBlocks(in)
if err != nil {
t.Fatal(err)
}
if len(entries) != len(in) {
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
}
var last uint64
for i, e := range entries {
seq, ok := recallIDToSeq(e.id)
if !ok || seq <= last {
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
}
last = seq
if e.label != in[i].Label {
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
}
blk, ok := recallGet(e.id)
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
}
}
// Un archivage isolé suit la même séquence.
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
if err != nil {
t.Fatal(err)
}
if seq, _ := recallIDToSeq(id); seq != last+1 {
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
}
}
+7 -3
View File
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
// qui rend la main, pas le délai de la commande.
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
if d := time.Since(start); d > 8*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
// « WaitDelay expired » (AJEAN 7403a8a0).
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
t.Errorf("sortie inattendue : %q", out)
}
}
+11
View File
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
detached := errors.Is(err, exec.ErrWaitDelay)
if detached {
err = nil
}
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if errOut != "" {
parts = append(parts, "stderr:\n"+errOut)
}
if detached {
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
}
return strings.Join(parts, "\n\n")
}
+75 -19
View File
@@ -16,6 +16,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
)
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
// Compilés à la première utilisation, pas à l'init du package : la surcharge
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
// choisi son rôle (CLI, serveur…).
//
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
// process (`loki config set` pendant que le service tourne).
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
retryMu sync.Mutex
retryRes []*regexp.Regexp
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
retryCustom bool
retryCustom bool
retryLoaded bool
retryStale bool // écriture locale du bucket d'état depuis la lecture
retryRaw string
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
retryChecked time.Time
retryMtime time.Time
retrySize int64
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
return retryRes
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
func retryStateBust() {
retryMu.Lock()
retryStale = true
retryMu.Unlock()
}
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
pats := defaultRetryPatterns
custom := false
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
pats, custom = over, true
func retryPatterns() []*regexp.Regexp {
res, _ := retryState()
return res
}
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
func retryState() ([]*regexp.Regexp, bool) {
retryMu.Lock()
defer retryMu.Unlock()
reload := !retryLoaded || retryStale || retryPath != dbPath()
if !reload && time.Since(retryChecked) >= time.Second {
retryChecked = time.Now()
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
reload = true
}
}
if reload {
raw := getStr(bkState, "retry_patterns")
retryMtime, retrySize = dbStamp()
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
if !retryLoaded || raw != retryRaw {
retryRes, retryCustom = compileRetryPatterns(raw)
retryRaw, retryLoaded = raw, true
}
}
return retryRes, retryCustom
}
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
// tour était relancé une fois (OpenFox 8e9b4869).
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
// couvre les cas connus.
if raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil {
if out := compileRetryList(over); len(out) > 0 {
return out, true
}
}
}
return compileRetryList(defaultRetryPatterns), false
}
func compileRetryList(pats []string) []*regexp.Regexp {
var out []*regexp.Regexp
for _, p := range pats {
if strings.TrimSpace(p) == "" {
continue
}
if re, err := regexp.Compile("(?mi)" + p); err == nil {
out = append(out, re)
}
}
return out, custom
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
if content == "" {
return false
}
res := retryPatterns()
res, custom := retryState()
start := 0
if !retryCustom {
if !custom {
start = retryScanStart(content, scanned)
}
w := content[start:]
+47
View File
@@ -0,0 +1,47 @@
package loki
import "testing"
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
// sur les motifs embarqués.
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
if !custom || len(res) != 1 {
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
}
if res[0].MatchString("une réponse tout à fait normale") {
t.Fatal("un texte ordinaire déclenche une relance")
}
res, custom = compileRetryPatterns(`["", "([invalide"]`)
if custom || len(res) != len(defaultRetryPatterns) {
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
}
if _, custom = compileRetryPatterns(`pas du json`); custom {
t.Fatal("surcharge illisible prise en compte")
}
}
// Un changement de retry_patterns agit sans redémarrer le process.
func TestRetryPatternsReloadOnChange(t *testing.T) {
testHome(t)
retryStateBust()
if _, custom := retryState(); custom {
t.Fatal("surcharge active sans clé")
}
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
t.Fatal(err)
}
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("nouveau motif ignoré après l'écriture")
}
if err := putStr(bkState, "retry_patterns", ""); err != nil {
t.Fatal(err)
}
if _, custom := retryState(); custom {
t.Fatal("surcharge retirée mais toujours active")
}
if textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("ancien motif encore appliqué")
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"errors"
"io"
@@ -857,10 +858,10 @@ func TestFacadeMCP(t *testing.T) {
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
if got := mcpCall(context.Background(), "mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
if got := mcpCall(context.Background(), "mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
@@ -872,7 +873,7 @@ func TestFacadeMCP(t *testing.T) {
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
if got := mcpCall(context.Background(), "mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
+7 -3
View File
@@ -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
}
+7 -2
View File
@@ -1373,6 +1373,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
lastEst := 0
// Compaction en cours de tour qui n'a rien pu réduire : la retenter
// seulement une fois le contexte nettement plus gros (compactRetryDue).
compactRetryAt := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
@@ -2468,7 +2471,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
publishHistory()
}
}
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
if compactNeeded(messages, used, peakGen) && compactRetryDue(compactRetryAt, used, messages) &&
!compactRefusedSkip(mainConv, used, messages) {
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
disableTools || toolChoiceNone)
yes, no := true, false
@@ -2476,6 +2480,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
compactRetryAt = compactNextRetry(changed, used, messages)
if changed {
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
messages = c
@@ -2810,7 +2815,7 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result, visionImg = toolCUScreenshot()
default:
if isMCPTool(name) {
result = mcpCall(name, args)
result = mcpCall(ctx, name, args)
} else {
result = "[erreur] outil inconnu: " + name
}
+85 -23
View File
@@ -25,6 +25,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -73,6 +74,14 @@ const (
lspMaxDiags = 10 // diagnostics montrés au modèle
)
// lspDiagQuiet : calme attendu après une publication avant de la croire
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
// les tests.
var lspDiagQuiet = 350 * time.Millisecond
type lspServer struct {
lang string
root string
@@ -82,11 +91,17 @@ type lspServer struct {
nextID int
version map[string]int // uri → version de document
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
diagMu sync.Mutex
diags map[string][]lspDiagnostic
diagMu sync.Mutex
diags map[string][]lspDiagnostic
// diagVer : version de document annoncée par la dernière publication de
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
diagVer map[string]int
diagCh chan string // uri fraîchement publié
lastUsed time.Time
dead bool
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
dead atomic.Bool
}
type lspDiagnostic struct {
@@ -214,7 +229,7 @@ func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
key := lang.ID + "\x00" + root
lspMu.Lock()
defer lspMu.Unlock()
if s := lspServers[key]; s != nil && !s.dead {
if s := lspServers[key]; s != nil && !s.dead.Load() {
return s, nil
}
s, err := lspStart(lang, root)
@@ -242,7 +257,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
}
s := &lspServer{
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(stdout)
@@ -268,7 +283,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dead {
if s.dead.Load() {
return nil, false
}
s.lastUsed = time.Now()
@@ -277,16 +292,29 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
return nil, false
}
uri := fileURI(absPath)
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
// que le serveur ait vu la nouvelle version.
for drained := false; !drained; {
select {
case <-s.diagCh:
default:
drained = true
}
}
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
delete(s.diagVer, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
s.version[uri] = 1
v = 1
s.version[uri] = v
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
},
})
} else {
@@ -297,24 +325,56 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
"contentChanges": []map[string]any{{"text": string(content)}},
})
}
// Attend la publication pour CET uri, bornée.
deadline := time.After(lspDiagWait)
if s.dead.Load() {
return nil, false // écriture ratée : le serveur ne verra jamais cette version
}
// current : les diagnostics publiés pour CETTE version (ou sans version),
// pas ceux d'une version antérieure arrivés en retard.
current := func() ([]lspDiagnostic, bool) {
s.diagMu.Lock()
defer s.diagMu.Unlock()
d, ok := s.diags[uri]
if !ok {
return nil, false
}
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
return nil, false
}
return d, true
}
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
// nouvelle publication ; le tout borné par lspDiagWait.
deadline := time.NewTimer(lspDiagWait)
defer deadline.Stop()
var quiet <-chan time.Time
var quietT *time.Timer
defer func() {
if quietT != nil {
quietT.Stop()
}
}()
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
if _, ok := current(); !ok {
continue // publication d'une version antérieure
}
if quietT == nil {
quietT = time.NewTimer(lspDiagQuiet)
} else {
quietT.Reset(lspDiagQuiet)
}
quiet = quietT.C
case <-quiet:
d, _ := current()
return d, true
case <-deadline:
case <-deadline.C:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
d, _ := current()
return d, true
}
}
@@ -353,6 +413,7 @@ func (s *lspServer) readLoop(r io.Reader) {
Method string `json:"method"`
Params struct {
URI string `json:"uri"`
Version *int `json:"version"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"params"`
}
@@ -362,6 +423,11 @@ func (s *lspServer) readLoop(r io.Reader) {
if msg.Method == "textDocument/publishDiagnostics" {
s.diagMu.Lock()
s.diags[msg.Params.URI] = msg.Params.Diagnostics
ver := -1
if msg.Params.Version != nil {
ver = *msg.Params.Version
}
s.diagVer[msg.Params.URI] = ver
s.diagMu.Unlock()
select {
case s.diagCh <- msg.Params.URI:
@@ -377,7 +443,7 @@ func (s *lspServer) idleWatch() {
for range t.C {
s.mu.Lock()
idle := time.Since(s.lastUsed) > lspIdleTimeout
dead := s.dead
dead := s.dead.Load()
s.mu.Unlock()
if dead {
return
@@ -401,11 +467,7 @@ func (s *lspServer) shutdown() {
lspMu.Unlock()
}
func (s *lspServer) markDead() {
s.mu.Lock()
s.dead = true
s.mu.Unlock()
}
func (s *lspServer) markDead() { s.dead.Store(true) }
func (s *lspServer) send(method string, params any, isRequest bool) {
s.nextID++
+200
View File
@@ -0,0 +1,200 @@
package loki
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeLSP : un serveur LSP réduit à ce que check utilise. Pour chaque
// didOpen/didChange reçu, react(version, publish) décide des publications.
type fakeLSP struct {
s *lspServer
out *io.PipeWriter
}
func lspFrame(w io.Writer, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(b), b)
}
func newFakeLSP(t *testing.T, react func(ver int, publish func(ver *int, diags []lspDiagnostic))) *fakeLSP {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
s := &lspServer{
lang: "go", root: t.TempDir(), stdin: inW,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(outR)
go func() {
br := bufio.NewReader(inR)
for {
n := 0
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
}
buf := make([]byte, n)
if _, err := io.ReadFull(br, buf); err != nil {
return
}
var msg struct {
Method string `json:"method"`
Params struct {
TextDocument struct {
URI string `json:"uri"`
Version int `json:"version"`
} `json:"textDocument"`
} `json:"params"`
}
if json.Unmarshal(buf, &msg) != nil {
continue
}
if msg.Method != "textDocument/didOpen" && msg.Method != "textDocument/didChange" {
continue
}
uri := msg.Params.TextDocument.URI
go react(msg.Params.TextDocument.Version, func(ver *int, diags []lspDiagnostic) {
params := map[string]any{"uri": uri, "diagnostics": diags}
if ver != nil {
params["version"] = *ver
}
lspFrame(outW, map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params})
})
}
}()
t.Cleanup(func() { inW.Close(); outW.Close() })
return &fakeLSP{s: s, out: outW}
}
func lspErr(msg string) []lspDiagnostic {
d := lspDiagnostic{Severity: 1, Message: msg}
return []lspDiagnostic{d}
}
func lspTempFile(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "main.go")
if err := os.WriteFile(p, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func withLSPQuiet(t *testing.T, d time.Duration) {
old := lspDiagQuiet
lspDiagQuiet = d
t.Cleanup(func() { lspDiagQuiet = old })
}
// Un signal laissé dans le tampon par une republication tardive ne doit pas
// faire rendre « aucun diagnostic » à l'édition suivante.
func TestLSPCheckIgnoresStaleSignal(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("undefined: x"))
})
uri := fileURI(p)
f.s.version[uri] = 1 // document déjà ouvert
f.s.diagCh <- uri // republication tardive du check précédent
f.s.diags[uri] = nil // …dont la liste était vide
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "undefined: x" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur de la nouvelle version", d, ok)
}
}
// Une liste vide publiée d'abord (« clear ») puis l'analyse : c'est l'analyse
// qui compte.
func TestLSPCheckWaitsPastInitialClear(t *testing.T) {
withLSPQuiet(t, 150*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(&ver, nil)
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("missing return"))
})
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "missing return" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur publiée après la liste vide", d, ok)
}
}
// Une publication d'une version antérieure, arrivée en retard, est ignorée.
func TestLSPCheckIgnoresOlderVersion(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
old := ver - 1
publish(&old, lspErr("ancienne erreur"))
time.Sleep(40 * time.Millisecond)
publish(&ver, nil)
})
f.s.version[fileURI(p)] = 3
d, ok := f.s.check(p)
if !ok || len(d) != 0 {
t.Fatalf("diagnostics = %+v, %v ; attendu aucun (seule la version 4 compte)", d, ok)
}
}
// Sans version dans la publication (champ facultatif), elle est prise.
func TestLSPCheckAcceptsUnversionedPublish(t *testing.T) {
withLSPQuiet(t, 50*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(nil, lspErr("syntax error"))
})
start := time.Now()
d, ok := f.s.check(p)
if !ok || len(d) != 1 {
t.Fatalf("diagnostics = %+v, %v", d, ok)
}
if time.Since(start) > lspDiagWait {
t.Fatalf("check a attendu la borne au lieu du calme")
}
}
// Une écriture ratée vers le serveur ne bloque plus check (markDead prenait
// mu, déjà tenu par check).
func TestLSPCheckWriteFailureDoesNotDeadlock(t *testing.T) {
p := lspTempFile(t)
r, w := io.Pipe()
r.Close() // toute écriture échoue
s := &lspServer{
lang: "go", stdin: w,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16),
}
done := make(chan struct{})
go func() {
s.check(p)
close(done)
}()
select {
case <-done:
case <-time.After(lspDiagWait + 2*time.Second):
t.Fatal("check bloqué après une écriture ratée")
}
if !s.dead.Load() {
t.Fatal("serveur pas marqué mort après une écriture ratée")
}
}
+101
View File
@@ -0,0 +1,101 @@
package loki
import (
"context"
"strings"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// fakeMCPSlow branche un serveur MCP en mémoire dont l'outil « slow » ne rend
// la main qu'à l'annulation de sa requête (ou au bout de 30 s).
func fakeMCPSlow(t *testing.T) string {
t.Helper()
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "lent", Version: "1"}, nil)
srv.AddTool(&mcpsdk.Tool{Name: "slow", InputSchema: map[string]any{"type": "object"}},
func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fini"}}}, nil
}
})
st, ct := mcpsdk.NewInMemoryTransports()
ctx := context.Background()
ss, err := srv.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
cs, err := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki-test", Version: "1"}, nil).Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
close(ready)
name := mcpExposedName("lent", "slow")
mcpMgr.mu.Lock()
if mcpMgr.sessions == nil {
mcpMgr.sessions = map[string]*mcpSession{}
}
if mcpMgr.registry == nil {
mcpMgr.registry = map[string]mcpToolRef{}
}
mcpMgr.sessions["lent"] = &mcpSession{name: "lent", sess: cs, ready: ready}
mcpMgr.registry[name] = mcpToolRef{server: "lent", tool: "slow"}
mcpMgr.mu.Unlock()
t.Cleanup(func() {
mcpMgr.mu.Lock()
delete(mcpMgr.sessions, "lent")
delete(mcpMgr.registry, name)
mcpMgr.mu.Unlock()
cs.Close()
ss.Close()
})
return name
}
// Le bouton stop annule l'appel MCP en cours au lieu d'attendre ses 120 s.
func TestMCPCallHonorsTurnCancel(t *testing.T) {
testHome(t)
name := fakeMCPSlow(t)
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(150*time.Millisecond, cancel)
start := time.Now()
out := mcpCall(ctx, name, map[string]any{})
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("l'appel a ignoré l'annulation du tour (%s)", d)
}
if !strings.Contains(out, "interrompu") {
t.Fatalf("sortie = %q ; attendu un appel interrompu", out)
}
}
func TestMCPCallTimeoutFor(t *testing.T) {
def := MCPTimeouts{}.normalized()
cases := []struct {
cfg MCPServerConfig
t MCPTimeouts
args map[string]any
want time.Duration
}{
{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.t, c.args); got != c.want {
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
}
}
}
+101 -27
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -35,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
@@ -193,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)
@@ -374,7 +374,11 @@ func isMCPTool(name string) bool {
// 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 {
//
// parent est le contexte DU TOUR, comme pour runShell : l'appel naissait d'un
// context.Background(), donc le bouton stop n'arrêtait rien et le tour restait
// bloqué jusqu'au bout des 120 s (OpenFox 2ef584b8).
func mcpCall(parent context.Context, name string, args map[string]any) string {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
return facadeMCPCall(name, args)
}
@@ -384,35 +388,94 @@ func mcpCall(name string, args map[string]any) string {
if !ok {
return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
}
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
timeout := mcpCallTimeoutFor(cfg, LoadMCPTimeouts(), args)
result, err := mcpCallOnce(ref, args)
result, err := mcpCallOnce(parent, ref, args, timeout)
if err != nil {
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
if errors.Is(err, context.DeadlineExceeded) {
// Le serveur est vivant mais l'outil est lent : le relancer
// doublerait l'attente (et l'effet de l'outil).
return fmt.Sprintf("[erreur MCP] pas de réponse après %s (délai de l'appel ; un argument timeout plus grand l'allonge)", timeout.Round(time.Second))
}
// 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 {
if result2, err2 := mcpCallOnce(parent, ref, args, timeout); err2 == nil {
return result2
} else {
err = err2
}
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
}
return "[erreur MCP] " + err.Error()
}
return result
}
func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
// 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.
// 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. 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, t MCPTimeouts, args map[string]any) time.Duration {
d := t.call()
if cfg.CallTimeoutSec > 0 {
d = time.Duration(cfg.CallTimeoutSec) * time.Second
}
if t := mcpArgTimeout(args); t > 0 && t+30*time.Second > d {
d = t + 30*time.Second
}
return min(d, mcpCallTimeoutMax)
}
// mcpArgTimeout lit l'argument « timeout » d'un appel. Les serveurs MCP ne
// s'accordent pas sur l'unité : au-delà de 3600 on le lit en millisecondes
// (5000 = 5 s), en deçà en secondes (600 = 10 min).
func mcpArgTimeout(args map[string]any) time.Duration {
var v float64
switch x := args["timeout"].(type) {
case float64:
v = x
case int:
v = float64(x)
case int64:
v = float64(x)
case json.Number:
v, _ = x.Float64()
case string:
v, _ = strconv.ParseFloat(strings.TrimSpace(x), 64)
}
if v <= 0 {
return 0
}
if v > 3600 {
return time.Duration(v) * time.Millisecond
}
return time.Duration(v * float64(time.Second))
}
func mcpCallOnce(parent context.Context, ref mcpToolRef, args map[string]any, timeout time.Duration) (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)
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
if err != nil {
@@ -536,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
@@ -559,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{}
@@ -581,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())
}
+2 -1
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"os/exec"
"strings"
"sync"
@@ -83,7 +84,7 @@ func TestMCPEndToEnd(t *testing.T) {
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
}
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
out := mcpCall(context.Background(), echoName, map[string]any{"message": "bonjour-loki"})
if !strings.Contains(out, "bonjour-loki") {
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
}
+5
View File
@@ -50,6 +50,11 @@ type MCPServerConfig struct {
// 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"`
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
// 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"`
}
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
+89
View File
@@ -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 ""
}
+171
View File
@@ -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"])
}
}
}
+70 -7
View File
@@ -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)
@@ -299,9 +362,9 @@ func TestPariteMCP(t *testing.T) {
results := map[string]string{}
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
name := mcpExposedName("alpha", n)
results[name] = mcpCall(name, callArgs[n])
results[name] = mcpCall(context.Background(), name, callArgs[n])
}
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
results["mcp__zeta__x"] = mcpCall(context.Background(), "mcp__zeta__x", nil)
fx["calls"] = results
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
+4
View File
@@ -222,6 +222,10 @@ func cacheBust(bucket string) {
cacheMu.Lock()
delete(caches, bucket)
cacheMu.Unlock()
if bucket == bkState {
webKeyCacheBust()
retryStateBust()
}
}
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
+10 -1
View File
@@ -340,15 +340,24 @@ func TestRetryScanStartWindows(t *testing.T) {
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
retryPatterns()
retryMu.Lock()
saved := retryRes
savedCustom := retryCustom
defer func() { retryRes, retryCustom = saved, savedCustom }()
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
retryChecked = time.Now() // pas de relecture pendant le test
retryMu.Unlock()
defer func() {
retryMu.Lock()
retryRes, retryCustom = saved, savedCustom
retryMu.Unlock()
}()
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
if !textualToolCallFrom(s, len(s)-10) {
t.Fatal("motif ancré en tête manqué")
}
retryMu.Lock()
retryCustom = false
retryMu.Unlock()
if textualToolCallFrom(s, len(s)-10) {
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
}
+66 -1
View File
@@ -2773,6 +2773,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></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 class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -3292,6 +3303,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</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;font-family:var(--font)"></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>
@@ -9502,7 +9517,47 @@ async function deleteExternal(){
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -9675,6 +9730,7 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -9702,6 +9758,8 @@ function openMcp(name){
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') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
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)'; }
@@ -9780,6 +9838,13 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
+15
View File
@@ -488,6 +488,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></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 class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -1007,6 +1018,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</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;font-family:var(--font)"></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>
+51 -1
View File
@@ -5,7 +5,47 @@
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -178,6 +218,7 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -205,6 +246,8 @@ function openMcp(name){
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') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
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)'; }
@@ -283,6 +326,13 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
+39
View File
@@ -1166,6 +1166,9 @@ type mcpSaveReq struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
// CallTimeoutSec : délai d'un appel propre au serveur, en secondes
// (0 ou absent = réglage global).
CallTimeoutSec int `json:"callTimeoutSec"`
}
// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée
@@ -1241,6 +1244,10 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpCallTimeoutSecError(req.CallTimeoutSec); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
@@ -1252,6 +1259,8 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
URL: strings.TrimSpace(req.URL),
Headers: req.Headers,
Enabled: enabled,
CallTimeoutSec: req.CallTimeoutSec,
}
if err := SetMCPServer(req.Name, cfg); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -1334,6 +1343,36 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPTimeouts renvoie les délais MCP globaux (secondes).
func handleMCPTimeouts(w http.ResponseWriter, r *http.Request) {
t := LoadMCPTimeouts()
sendJSON(w, 200, map[string]any{"ok": true, "connect": t.Connect, "call": t.Call})
}
// handleMCPTimeoutsSave enregistre les délais MCP globaux (1 à 3600 s) et
// ferme les sessions ouvertes, pour que le délai de connexion s'applique dès
// la suivante.
func handleMCPTimeoutsSave(w http.ResponseWriter, r *http.Request) {
var req MCPTimeouts
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpTimeoutsError(req); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
if err := SaveMCPTimeouts(req); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
servers, _ := LoadMCPConfig()
for name := range servers {
mcpInvalidate(name)
}
sendJSON(w, 200, map[string]any{"ok": true, "connect": req.Connect, "call": req.Call})
}
// 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.
+58 -2
View File
@@ -10,6 +10,8 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
)
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
@@ -19,7 +21,8 @@ import (
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
// et inversement. Elle est relue à chaque requête,
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
// changement de clé prenne effet sans redémarrer le serveur web.
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
@@ -27,9 +30,62 @@ import (
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
//
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
// mise en cache : elle remonte, et l'API reste fermée.
func readWebKeyErr() (string, error) {
path := dbPath()
mtime, size := dbStamp()
c := &webKeyCache
c.Lock()
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
k := c.key
c.Unlock()
return k, nil
}
gen := c.gen
c.Unlock()
b, err := getBytesErr(bkState, "web_key")
return string(b), err
if err != nil {
return "", err
}
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
// couverte par l'âge maximal.
mtime, size = dbStamp()
c.Lock()
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
// l'ancienne — ne pas la garder.
if c.gen == gen {
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
}
c.Unlock()
return string(b), nil
}
var webKeyCache struct {
sync.Mutex
ok bool
gen uint64 // incrémenté à chaque invalidation
path string // la base lue (LOKI_HOME change d'un test à l'autre)
key string
when time.Time
mtime time.Time
size int64
}
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
func webKeyCacheBust() {
webKeyCache.Lock()
webKeyCache.ok = false
webKeyCache.gen++
webKeyCache.Unlock()
}
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
+88
View File
@@ -0,0 +1,88 @@
package loki
import (
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// Une écriture de ce process est vue tout de suite : le cache ne retarde pas un
// changement de clé.
func TestWebKeyCacheInvalidatedByLocalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "ancienne"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "ancienne" {
t.Fatalf("clé = %q, %v", k, err)
}
if err := putStr(bkState, "web_key", "nouvelle"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "nouvelle" {
t.Fatalf("clé après écriture = %q, %v ; attendu la nouvelle", k, err)
}
}
// Une écriture d'un AUTRE process (sans cacheBust ici) finit vue : au plus
// cacheMaxAge plus tard.
func TestWebKeyCacheSeesExternalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "a"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "a" {
t.Fatalf("clé = %q", k)
}
// Écriture directe, comme le ferait la CLI : pas de cacheBust.
err := withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bkState)).Put([]byte("web_key"), []byte("b"))
})
})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(cacheMaxAge + 500*time.Millisecond)
for {
if k, _ := readWebKeyErr(); k == "b" {
return
}
if time.Now().After(deadline) {
t.Fatal("écriture externe jamais vue")
}
time.Sleep(50 * time.Millisecond)
}
}
// Sans changement, les lectures suivantes n'ouvrent plus la base.
func TestWebKeyCacheServesRepeatedReads(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "k"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "k" {
t.Fatalf("clé = %q", k)
}
// Base verrouillée par « un autre process » : une lecture qui l'ouvrirait
// attendrait la borne de 5 s puis échouerait.
dbMu.Lock()
type res struct {
k string
err error
}
ch := make(chan res, 1)
go func() { k, err := readWebKeyErr(); ch <- res{k, err} }()
select {
case r := <-ch:
dbMu.Unlock()
if r.err != nil || r.k != "k" {
t.Fatalf("clé = %q, %v", r.k, r.err)
}
case <-time.After(300 * time.Millisecond):
dbMu.Unlock()
<-ch
t.Fatal("lecture servie par la base, pas par le cache")
}
}
+2
View File
@@ -334,6 +334,8 @@ func newWebMux() *http.ServeMux {
api("/api/mcp/toggle", handleMCPToggle)
api("/api/mcp/tool", handleMCPTool)
api("/api/mcp/test", handleMCPTest)
api("/api/mcp/timeouts", handleMCPTimeouts)
api("/api/mcp/timeouts/save", handleMCPTimeoutsSave)
api("/api/memory", handleMemoryMode)
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
+20 -3
View File
@@ -922,11 +922,19 @@ impl RetryPatterns {
if over.is_empty() {
return None;
}
let pats = over
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
// motif utilisable, les motifs embarqués, comme le Go
// (compileRetryPatterns).
let pats: Vec<Box<dyn RetryPattern>> = over
.iter()
.filter(|p| !p.trim().is_empty())
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
.collect();
if pats.is_empty() {
return None;
}
Some(RetryPatterns::custom(pats))
}
@@ -2907,10 +2915,19 @@ mod tests {
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
#[test]
fn surcharge_retry_patterns() {
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
for raw in [
"",
"null",
"[]",
"[\"a\", 1]",
"\"x\"",
"[\"a\"] x",
"[\"\"]",
"[\" \", \"(\"]",
] {
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
}
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
assert!(p.is_custom());
assert_eq!(p.len(), 2, "la regex illisible est sautée");
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
+243 -15
View File
@@ -25,6 +25,14 @@ pub struct ServerConfig {
pub enabled: bool,
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
/// secondes ; 0 = réglage global ([`Timeouts::call`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
impl Default for ServerConfig {
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
headers: BTreeMap::new(),
enabled: true,
disabled_tools: Vec::new(),
call_timeout_sec: 0,
}
}
}
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
Some(x) => x.as_bool().unwrap_or(false),
},
disabled_tools: list("disabledTools"),
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
})
}
}
@@ -275,13 +285,146 @@ pub fn prompt_line(tools: &[(String, String)]) -> String {
)
}
/// `mcpConnectTimeout`.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier
/// lancement.
/// lancement ; un minimum, un délai de connexion réglé plus long l'emporte.
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpTimeoutMaxSec` : borne haute des réglages (1 h).
pub const TIMEOUT_MAX_SECS: i64 = 3600;
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config ; il suit la borne des réglages.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(TIMEOUT_MAX_SECS as u64);
/// `mcpTimeoutsKey` : clé des délais globaux (`state/mcp_timeouts`).
pub const ST_TIMEOUTS: &str = "mcp_timeouts";
/// `MCPTimeouts` : les délais globaux, en secondes (`mcp_timeouts.go`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timeouts {
pub connect: i64,
pub call: i64,
}
impl Default for Timeouts {
/// 20 s de connexion, 120 s d'appel.
fn default() -> Self {
Timeouts {
connect: 0,
call: 0,
}
.normalized()
}
}
impl Timeouts {
/// `normalized` : nul ou négatif = défaut, au-delà de la borne = la borne.
pub fn normalized(self) -> Self {
let fix = |v: i64, def: i64| if v <= 0 { def } else { v.min(TIMEOUT_MAX_SECS) };
Timeouts {
connect: fix(self.connect, 20),
call: fix(self.call, 120),
}
}
pub fn connect(&self) -> Duration {
Duration::from_secs(self.connect.max(0) as u64)
}
pub fn call(&self) -> Duration {
Duration::from_secs(self.call.max(0) as u64)
}
/// `json.Marshal(MCPTimeouts)`.
pub fn marshal(&self) -> String {
format!(r#"{{"connect":{},"call":{}}}"#, self.connect, self.call)
}
}
/// Le type Go décodé (`MCPTimeouts`) et ses champs, pour [`crate::gojson`].
pub const TIMEOUTS_TYPE: &str = "MCPTimeouts";
pub const TIMEOUTS_FIELDS: [(&str, crate::gojson::Kind); 2] = [
("connect", crate::gojson::Kind::Int),
("call", crate::gojson::Kind::Int),
];
/// `LoadMCPTimeouts` : `getJSON` puis normalisation. Une valeur que
/// `json.Unmarshal` refuse, même en partie, donne les défauts.
pub fn decode_timeouts(raw: Option<&[u8]>) -> Timeouts {
let raw = raw.unwrap_or_default();
if raw.is_empty() || serde_json::from_slice::<Value>(raw).is_err() {
return Timeouts::default();
}
let d = crate::gojson::decode_struct(raw, TIMEOUTS_TYPE, &TIMEOUTS_FIELDS);
if d.err.is_some() {
return Timeouts::default();
}
Timeouts {
connect: d.int("connect"),
call: d.int("call"),
}
.normalized()
}
/// `mcpTimeoutsError` : le refus d'un réglage hors bornes.
pub fn timeouts_error(t: &Timeouts) -> Option<&'static str> {
if !(1..=TIMEOUT_MAX_SECS).contains(&t.connect) {
return Some("connexion : 1 à 3600 s");
}
if !(1..=TIMEOUT_MAX_SECS).contains(&t.call) {
return Some("appel d'outil : 1 à 3600 s");
}
None
}
/// `mcpCallTimeoutSecError` : le refus d'un délai par serveur hors bornes.
pub fn call_timeout_sec_error(sec: i64) -> Option<&'static str> {
if !(0..=TIMEOUT_MAX_SECS).contains(&sec) {
return Some("délai d'un appel : 0 à 3600 s (vide = réglage global)");
}
None
}
/// `mcpCallTimeoutFor` : le réglage global, remplacé par `callTimeoutSec` du
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map<String, Value>) -> Duration {
let mut d = tm.call();
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
if let Some(t) = arg_timeout(args) {
let t = t + Duration::from_secs(30);
if t > d {
d = t;
}
}
d.min(CALL_TIMEOUT_MAX)
}
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
let v = match args.get("timeout")? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
if !v.is_finite() || v <= 0.0 {
return None;
}
if v > 3600.0 {
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
return Some(Duration::from_millis(v.min(1e12) as u64));
}
Some(Duration::from_secs_f64(v))
}
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
pub fn call_deadline_error(timeout: Duration) -> String {
let secs = (timeout.as_millis() + 500) / 1000;
format!(
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
)
}
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
/// `.cmd`.
@@ -297,23 +440,27 @@ fn launcher(cfg: &ServerConfig) -> String {
b.strip_suffix(".cmd").unwrap_or(b).to_string()
}
/// `mcpTimeoutFor`.
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
/// `mcpTimeoutFor` : le réglage global, au moins [`CONNECT_TIMEOUT_COLD`]
/// pour npx et uvx.
pub fn timeout_for(cfg: &ServerConfig, tm: &Timeouts) -> Duration {
match launcher(cfg).as_str() {
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
_ => CONNECT_TIMEOUT,
"npx" | "uvx" => tm.connect().max(CONNECT_TIMEOUT_COLD),
_ => tm.connect(),
}
}
/// `mcpFriendlyErr` pour un délai dépassé.
pub fn timeout_error(cfg: &ServerConfig) -> String {
pub fn timeout_error(cfg: &ServerConfig, tm: &Timeouts) -> String {
let l = launcher(cfg);
if l == "npx" || l == "uvx" {
return format!(
"délai dépassé — {l} 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)"
);
}
"délai dépassé — le serveur n'a pas répondu au handshake en 20s".into()
format!(
"délai dépassé — le serveur n'a pas répondu au handshake en {} (délai de connexion réglable dans les réglages MCP)",
crate::tools::shell::go_duration(tm.connect())
)
}
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
@@ -463,6 +610,55 @@ fn resource_contents_json(v: &Value) -> String {
mod tests {
use super::*;
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
#[test]
fn delai_d_appel() {
let cfg = |n: i64| ServerConfig {
call_timeout_sec: n,
..Default::default()
};
let args = |v: Value| {
let mut m = Map::new();
m.insert("timeout".into(), v);
m
};
let s = Duration::from_secs;
let none = Map::new();
let def = Timeouts::default();
let ct = |c: &ServerConfig, a: &Map<String, Value>| call_timeout_for(c, &def, a);
assert_eq!(ct(&cfg(0), &none), s(120));
assert_eq!(ct(&cfg(600), &none), s(600));
assert_eq!(ct(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(ct(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(ct(&cfg(0), &args(json!(5000.0))), s(120));
assert_eq!(ct(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(
ct(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(ct(&cfg(30), &none), s(30));
// Le réglage global, remplacé par celui du serveur, jamais rogné.
let tm = |call| Timeouts { connect: 20, call };
assert_eq!(call_timeout_for(&cfg(0), &tm(45), &none), s(45));
assert_eq!(call_timeout_for(&cfg(90), &tm(45), &none), s(90));
assert_eq!(call_timeout_for(&cfg(0), &tm(3600), &none), s(3600));
assert_eq!(
call_deadline_error(s(330)),
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
);
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
let c: ServerConfig =
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
assert_eq!(c.call_timeout_sec, 45);
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
assert!(
serde_json::to_value(cfg(0))
.unwrap()
.get("callTimeoutSec")
.is_none()
);
}
#[test]
fn noms() {
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
@@ -475,9 +671,41 @@ mod tests {
command: cmd.into(),
..Default::default()
};
assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT);
let def = Timeouts::default();
assert_eq!(timeout_for(&c("/usr/bin/npx"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node"), &def), Duration::from_secs(20));
// Un délai réglé plus long que le minimum des lanceurs l'emporte.
let long = Timeouts {
connect: 600,
call: 120,
};
assert_eq!(timeout_for(&c("npx"), &long), Duration::from_secs(600));
assert_eq!(timeout_for(&c("node"), &long), Duration::from_secs(600));
}
#[test]
fn delais_globaux() {
let d = |raw: &str| decode_timeouts(Some(raw.as_bytes()));
let t = |connect, call| Timeouts { connect, call };
assert_eq!(decode_timeouts(None), t(20, 120));
assert_eq!(d(r#"{"connect":5,"call":300}"#), t(5, 300));
assert_eq!(d(r#"{"connect":0,"call":0}"#), t(20, 120));
assert_eq!(d(r#"{"connect":-4,"call":99999}"#), t(20, 3600));
assert_eq!(d(r#"{"connect":"x","call":50}"#), t(20, 120));
assert_eq!(d(r#"{"CONNECT":7}"#), t(7, 120));
assert_eq!(d("null"), t(20, 120));
assert_eq!(d(r#"{"connect":30,"call":40} reste"#), t(20, 120));
assert_eq!(t(30, 600).marshal(), r#"{"connect":30,"call":600}"#);
assert_eq!(timeouts_error(&t(0, 120)), Some("connexion : 1 à 3600 s"));
assert_eq!(
timeouts_error(&t(20, 3601)),
Some("appel d'outil : 1 à 3600 s")
);
assert_eq!(timeouts_error(&t(3600, 1)), None);
assert!(call_timeout_sec_error(-1).is_some());
assert!(call_timeout_sec_error(0).is_none());
assert!(call_timeout_sec_error(3601).is_some());
}
}
+108 -18
View File
@@ -29,7 +29,7 @@ use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
use client::Session;
use config::{ServerConfig, Servers};
use config::{ServerConfig, Servers, Timeouts};
/// Clé de la configuration (`state/mcp`).
pub const ST_MCP: &str = "mcp";
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
const CALL_DEADLINE: &str = "context deadline exceeded";
/// Issue d'une tentative de connexion.
#[derive(Default)]
struct SlotState {
@@ -105,6 +108,26 @@ impl Mcp {
Ok(())
}
/// `SaveMCPTimeouts` puis la fermeture des sessions des serveurs
/// configurés : le nouveau délai de connexion vaut dès la suivante.
async fn save_timeouts(&self, store: &Store, t: Timeouts) -> Result<(), String> {
let names: Vec<String> = {
let _g = self.cfg_lock.lock().await;
store
.put(
BK_STATE,
config::ST_TIMEOUTS,
t.normalized().marshal().into_bytes(),
)
.await?;
Self::load(store).await.keys().cloned().collect()
};
for name in names {
self.invalidate(&name).await;
}
Ok(())
}
/// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils
/// retirés du registre.
pub async fn invalidate(&self, name: &str) {
@@ -133,8 +156,27 @@ impl Mcp {
}
}
/// `ensure` : la session du serveur, connectée ou en erreur.
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
/// `LoadMCPTimeouts` : les délais globaux (`state/mcp_timeouts`).
pub async fn timeouts(store: &Store) -> Timeouts {
config::decode_timeouts(
store
.get(BK_STATE, config::ST_TIMEOUTS)
.await
.ok()
.flatten()
.as_deref(),
)
}
/// `ensure` : la session du serveur, connectée ou en erreur. Le délai de
/// connexion est lu au moment de se connecter.
async fn ensure(
&self,
store: &Store,
name: &str,
cfg: &ServerConfig,
version: &str,
) -> Arc<Slot> {
let slot = lock(&self.slots)
.entry(name.to_string())
.or_insert_with(|| {
@@ -147,14 +189,15 @@ impl Mcp {
slot.state
.get_or_init(|| async {
let cfg = &slot.cfg;
let tm = Self::timeouts(store).await;
let mut st = SlotState::default();
let work = async {
let sess = Session::connect(cfg, version).await?;
let tools = sess.list_tools().await;
Ok::<_, String>((sess, tools))
};
match tokio::time::timeout(config::timeout_for(cfg), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg)),
match tokio::time::timeout(config::timeout_for(cfg, &tm), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg, &tm)),
Ok(Err(e)) => st.err = Some(e),
Ok(Ok((sess, tools))) => {
match tools {
@@ -188,12 +231,12 @@ impl Mcp {
}
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
async fn ensure_all(&self, servers: &Servers, version: &str) {
async fn ensure_all(&self, store: &Store, servers: &Servers, version: &str) {
join_all(
servers
.iter()
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
.map(|(n, c)| self.ensure(n, c, version)),
.map(|(n, c)| self.ensure(store, n, c, version)),
)
.await;
}
@@ -205,13 +248,13 @@ impl Mcp {
if servers.is_empty() {
return Vec::new();
}
self.ensure_all(&servers, version).await;
self.ensure_all(store, &servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
if !cfg.enabled || cfg.transport().is_empty() {
continue;
}
let slot = self.ensure(name, cfg, version).await;
let slot = self.ensure(store, name, cfg, version).await;
let Some(st) = slot.state.get() else { continue };
if st.sess.is_none() {
continue;
@@ -251,15 +294,23 @@ impl Mcp {
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let mut err = match self.call_once(&server, &tool, args, cap).await {
let servers = Self::load(store).await;
let timeout = config::call_timeout_for(
&servers.get(&server).cloned().unwrap_or_default(),
&Self::timeouts(store).await,
args,
);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
};
let servers = Self::load(store).await;
if err == CALL_DEADLINE {
return config::call_deadline_error(timeout);
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(&server, cfg, version).await;
match self.call_once(&server, &tool, args, cap).await {
self.ensure(store, &server, cfg, version).await;
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
}
@@ -273,6 +324,7 @@ impl Mcp {
tool: &str,
args: &Map<String, Value>,
cap: usize,
timeout: std::time::Duration,
) -> Result<String, String> {
let slot = lock(&self.slots).get(server).cloned();
let sess = slot
@@ -281,9 +333,9 @@ impl Mcp {
.and_then(|st| st.sess.clone())
.filter(|s| !s.is_closed())
.ok_or_else(|| format!("serveur {server} non connecté"))?;
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
.await
.map_err(|_| "context deadline exceeded".to_string())??;
.map_err(|_| CALL_DEADLINE.to_string())??;
let out = config::cap_output(config::flatten_content(&res), cap);
if res.get("isError").and_then(Value::as_bool) == Some(true) {
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
@@ -298,7 +350,7 @@ impl Mcp {
/// activés.
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
let servers = Self::load(store).await;
self.ensure_all(&servers, version).await;
self.ensure_all(store, &servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
let mut st = json!({
@@ -315,11 +367,13 @@ impl Mcp {
("env", json!(cfg.env)),
("url", json!(cfg.url)),
("headers", json!(cfg.headers)),
("callTimeoutSec", json!(cfg.call_timeout_sec)),
] {
let empty = match &v {
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
Value::Number(n) => n.as_i64() == Some(0),
_ => false,
};
if !empty {
@@ -327,7 +381,7 @@ impl Mcp {
}
}
if cfg.enabled && !cfg.transport().is_empty() {
let slot = self.ensure(name, cfg, version).await;
let slot = self.ensure(store, name, cfg, version).await;
if let Some(s) = slot.state.get() {
if s.sess.is_some() {
st["connected"] = json!(true);
@@ -358,7 +412,7 @@ impl Mcp {
}
let servers = Self::load(&store).await;
if !servers.is_empty() {
me.ensure_all(&servers, &version).await;
me.ensure_all(&store, &servers, &version).await;
}
});
}
@@ -485,6 +539,8 @@ pub fn handles(path: &str) -> bool {
| "/api/mcp/toggle"
| "/api/mcp/tool"
| "/api/mcp/test"
| "/api/mcp/timeouts"
| "/api/mcp/timeouts/save"
)
}
@@ -553,11 +609,16 @@ pub async fn serve(
("url", Kind::Str),
("headers", Kind::StrMap),
("enabled", Kind::OptBool),
("callTimeoutSec", Kind::Int),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let call_timeout_sec = d.int("callTimeoutSec");
if let Some(e) = config::call_timeout_sec_error(call_timeout_sec) {
return fail(StatusCode::BAD_REQUEST, e);
}
let cfg = ServerConfig {
command: d.str("command").trim().to_string(),
args: d.str_list("args").unwrap_or_default(),
@@ -570,6 +631,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec,
};
let name = d.str("name");
let res = mcp
@@ -644,6 +706,34 @@ pub async fn serve(
mcp.invalidate(&d.str("name")).await;
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/timeouts" => {
let t = Mcp::timeouts(store).await;
send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
)
}
"/api/mcp/timeouts/save" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(&raw, config::TIMEOUTS_TYPE, &config::TIMEOUTS_FIELDS);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let t = Timeouts {
connect: d.int("connect"),
call: d.int("call"),
};
if let Some(e) = config::timeouts_error(&t) {
return fail(StatusCode::BAD_REQUEST, e);
}
match mcp.save_timeouts(store, t).await {
Ok(()) => send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
),
Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e),
}
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
+136 -2
View File
@@ -66,17 +66,44 @@ fn parite_des_fonctions_pures() {
.unwrap_or_default();
assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}");
}
let def = cf::Timeouts::default();
for (cmd, v) in fx["timeouts"].as_object().unwrap() {
let cfg = cf::ServerConfig {
command: cmd.clone(),
..Default::default()
};
assert_eq!(
cf::timeout_for(&cfg).as_secs() as i64,
cf::timeout_for(&cfg, &def).as_secs() as i64,
v["secs"].as_i64().unwrap(),
"{cmd}"
);
assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}");
assert_eq!(cf::timeout_error(&cfg, &def), s(&v["err"]), "{cmd}");
}
// Délais globaux : lecture de la base, puis leur effet.
let tm = |v: &Value| cf::Timeouts {
connect: v["connect"].as_i64().unwrap(),
call: v["call"].as_i64().unwrap(),
};
for c in fx["timeouts_stored"].as_array().unwrap() {
let raw = s(&c["in"]);
let got = cf::decode_timeouts((!raw.is_empty()).then_some(raw.as_bytes()));
assert_eq!(got, tm(&c["out"]), "{raw}");
}
for c in fx["timeouts_effects"].as_array().unwrap() {
let t = tm(&c["timeouts"]);
let cfg: cf::ServerConfig = serde_json::from_str(s(&c["cfg"])).unwrap();
let args: Map<String, Value> = serde_json::from_str(s(&c["args"])).unwrap();
assert_eq!(
cf::timeout_for(&cfg, &t).as_millis() as i64,
c["connect"].as_i64().unwrap(),
"{c}"
);
assert_eq!(cf::timeout_error(&cfg, &t), s(&c["err"]), "{c}");
assert_eq!(
cf::call_timeout_for(&cfg, &t, &args).as_millis() as i64,
c["call"].as_i64().unwrap(),
"{c}"
);
}
for c in fx["flatten"].as_array().unwrap() {
let v: Value = serde_json::from_str(s(&c["in"])).unwrap();
@@ -422,6 +449,50 @@ async fn routes_de_configuration() {
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["url"], json!(url));
assert!(stored["web"].get("callTimeoutSec").is_none());
// Délai d'un appel propre au serveur : rangé, rendu, puis retiré quand le
// champ revient vide.
let body = |ct: Option<i64>| {
let mut b = json!({ "name": "web", "url": url, "headers": { "X-Cle": "k" } });
if let Some(n) = ct {
b["callTimeoutSec"] = json!(n);
}
b.to_string()
};
let (c, v) = post("/api/mcp/save", body(Some(900))).await;
assert_eq!(
(c, v["servers"][0]["callTimeoutSec"].clone()),
(200, json!(900))
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["callTimeoutSec"], 900);
let (c, v) = post("/api/mcp/save", body(None)).await;
assert_eq!(c, 200);
assert!(v["servers"][0].get("callTimeoutSec").is_none(), "{v}");
for n in [-1, 3601] {
let (c, v) = post("/api/mcp/save", body(Some(n))).await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!("délai d'un appel : 0 à 3600 s (vide = réglage global)")
)
);
}
let (c, v) = post(
"/api/mcp/save",
r#"{"name":"x","url":"http://h","callTimeoutSec":"9"}"#.into(),
)
.await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!(
"json: cannot unmarshal string into Go struct field mcpSaveReq.callTimeoutSec of type int"
)
)
);
// Erreurs de la configuration et du JSON.
let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await;
assert_eq!(
@@ -506,6 +577,67 @@ async fn routes_de_configuration() {
pool.close_all().await;
}
/// Les routes des délais globaux rejouées contre la façade : mêmes codes,
/// mêmes réponses, même valeur rangée ; un enregistrement réussi ferme les
/// sessions ouvertes.
#[tokio::test]
async fn routes_des_delais() {
let tmp = tempfile::tempdir().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let fx = fixture();
let url = fake_server(fx["wire"].clone(), seen.clone()).await;
let pool = Arc::new(Mcp::new(tmp.path().to_path_buf()));
let (facade, go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
mcp: Some(pool.clone()),
..Default::default()
},
)
.await;
go.set(
"state",
"mcp",
&json!({ "web": { "url": url, "enabled": true } }).to_string(),
);
let initialize = || {
seen.lock()
.unwrap()
.iter()
.filter(|l| l.starts_with("initialize"))
.count()
};
let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["servers"][0]["connected"], true, "{v}");
let mut connections = initialize();
for c in fx["timeouts_routes"].as_array().unwrap() {
let path = s(&c["path"]);
let method = if path.ends_with("/save") {
"POST"
} else {
"GET"
};
let (code, b) = fakego::send(facade, method, path, &[], s(&c["body"])).await;
assert_eq!(code.as_u16() as i64, c["code"].as_i64().unwrap(), "{c}");
let got: Value = serde_json::from_slice(&b).unwrap();
let want: Value = serde_json::from_str(s(&c["resp"])).unwrap();
assert_eq!(got, want, "{c}");
assert_eq!(
go.get("state", cf::ST_TIMEOUTS).unwrap_or_default(),
s(&c["state"]),
"{c}"
);
// Session fermée : la liste suivante se reconnecte.
fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let now = initialize();
let saved = method == "POST" && code.as_u16() == 200;
assert_eq!(now, connections + usize::from(saved), "{c}");
connections = now;
}
pool.close_all().await;
}
#[tokio::test]
async fn catalogue_embarque() {
let tmp = tempfile::tempdir().unwrap();
@@ -544,6 +676,8 @@ async fn sans_la_variable_mcp_reste_au_go() {
"/api/mcp/save",
"/api/mcp/catalog",
"/api/mcp/test",
"/api/mcp/timeouts",
"/api/mcp/timeouts/save",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
+108 -108
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -358,8 +358,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -387,8 +387,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -410,8 +410,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -433,8 +433,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -542,8 +542,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -947,8 +947,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -993,8 +993,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1132,8 +1132,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1241,8 +1241,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1646,8 +1646,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1686,8 +1686,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131201 octets e3226a117903>",
"content": "<131145 octets 8d4ca4a3239d>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1813,8 +1813,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_ms": 16395,
"prompt_n": 32790,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets 55a9fdff5d02>",
"content": "<3745 octets 4d9bad4c301c>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2314,8 +2314,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2443,8 +2443,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2595,8 +2595,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2770,8 +2770,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3015,8 +3015,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3144,8 +3144,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3189,8 +3189,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3342,8 +3342,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3383,8 +3383,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3512,8 +3512,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3579,8 +3579,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3760,8 +3760,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8144 octets 5c98ee6b6194>",
"content": "<8130 octets 081c43a7796f>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3975,8 +3975,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4034,8 +4034,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4057,8 +4057,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4089,8 +4089,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4112,8 +4112,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4135,8 +4135,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_per_second": 2000,
"protocol": 2
}
+446 -14
View File
@@ -163,37 +163,37 @@
{
"op": "set",
"name": "web",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}}"
},
{
"op": "enable",
"name": "web",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "enable",
"name": "nope",
"on": true,
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "write",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
@@ -201,21 +201,21 @@
"tool": "write",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "tool",
"name": "nope",
"tool": "x",
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
},
{
"op": "delete",
@@ -382,13 +382,14 @@
"connected": false,
"tools": [],
"disabled": [],
"url": "{{URL}}"
"url": "{{URL}}",
"callTimeoutSec": 300
}
],
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false},\"delta\":{\"enabled\":true}}",
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false,\"callTimeoutSec\":300},\"delta\":{\"enabled\":true}}",
"timeouts": {
"": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"/usr/local/bin/NPX": {
@@ -400,7 +401,7 @@
"secs": 180
},
"node": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"npx": {
@@ -412,7 +413,7 @@
"secs": 180
},
"uvx-x": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"secs": 20
},
"uvx.exe": {
@@ -420,6 +421,437 @@
"secs": 180
}
},
"timeouts_effects": [
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx 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)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx 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)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx 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)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx 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)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — npx 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)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 600000,
"err": "délai dépassé — uvx 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)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":600}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx 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)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx 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)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
}
],
"timeouts_routes": [
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":120,\"connect\":20,\"ok\":true}",
"state": ""
},
{
"body": "{\"connect\":0,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"connexion : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20,\"call\":3601}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":\"20\",\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":2.5,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal number 2.5 into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "[]",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal array into Go value of type loki.MCPTimeouts\",\"ok\":false}",
"state": ""
},
{
"body": "pas du json",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"invalid character 'p' looking for beginning of value\",\"ok\":false}",
"state": ""
},
{
"body": "{\"Connect\":45,\"CALL\":900,\"x\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "{\"connect\":3600,\"call\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
}
],
"timeouts_stored": [
{
"in": "",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":5,\"call\":300}",
"out": {
"connect": 5,
"call": 300
}
},
{
"in": "{\"connect\":0,\"call\":0}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":-4,\"call\":99999}",
"out": {
"connect": 20,
"call": 3600
}
},
{
"in": "{\"connect\":\"x\",\"call\":50}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"CONNECT\":7,\"Call\":8}",
"out": {
"connect": 7,
"call": 8
}
},
{
"in": "null",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "[1]",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":30,\"call\":40} reste",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":1.5,\"call\":60}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":3600,\"call\":3600,\"autre\":1}",
"out": {
"connect": 3600,
"call": 3600
}
}
],
"tool_server": [
{
"desc": "[MCP: alpha] desc",