From 4c2cf8b4e6ffc0e2414763ffbdc24bcf8021c147 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 16 Oct 2026 14:59:18 +0200 Subject: [PATCH] =?UTF-8?q?Fa=C3=A7ade=20:=20d=C3=A9lai=20d'appel=20MCP=20?= =?UTF-8?q?par=20serveur=20et=20par=20argument,=20pas=20de=20relance=20au?= =?UTF-8?q?=20d=C3=A9lai=20d=C3=A9pass=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rust/loki-rs/src/mcp/config.rs | 102 +++++++++++++++++++++++++++++++++ rust/loki-rs/src/mcp/mod.rs | 20 +++++-- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/rust/loki-rs/src/mcp/config.rs b/rust/loki-rs/src/mcp/config.rs index f7eaf48..262130e 100644 --- a/rust/loki-rs/src/mcp/config.rs +++ b/rust/loki-rs/src/mcp/config.rs @@ -25,6 +25,14 @@ pub struct ServerConfig { pub enabled: bool, #[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")] pub disabled_tools: Vec, + /// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en + /// secondes ; 0 = défaut ([`CALL_TIMEOUT`]). + #[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), }) } } @@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180); /// `mcpCallTimeout`. pub const CALL_TIMEOUT: Duration = Duration::from_secs(120); +/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la +/// config. +pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60); + +/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du +/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s. +pub fn call_timeout_for(cfg: &ServerConfig, args: &Map) -> Duration { + let mut d = CALL_TIMEOUT; + 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) -> Option { + let v = match args.get("timeout")? { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.trim().parse::().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`. @@ -463,6 +520,51 @@ 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(); + assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT); + assert_eq!(call_timeout_for(&cfg(600), &none), s(600)); + assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330)); + assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230)); + assert_eq!( + call_timeout_for(&cfg(0), &args(json!(5000.0))), + CALL_TIMEOUT + ); + assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430)); + assert_eq!( + call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))), + CALL_TIMEOUT_MAX + ); + assert_eq!(call_timeout_for(&cfg(30), &none), s(30)); + 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"); diff --git a/rust/loki-rs/src/mcp/mod.rs b/rust/loki-rs/src/mcp/mod.rs index 312a932..6fd77b1 100644 --- a/rust/loki-rs/src/mcp/mod.rs +++ b/rust/loki-rs/src/mcp/mod.rs @@ -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 { @@ -251,15 +254,20 @@ 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(), 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 { + match self.call_once(&server, &tool, args, cap, timeout).await { Ok(r) => return r, Err(e) => err = e, } @@ -273,6 +281,7 @@ impl Mcp { tool: &str, args: &Map, cap: usize, + timeout: std::time::Duration, ) -> Result { let slot = lock(&self.slots).get(server).cloned(); let sess = slot @@ -281,9 +290,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}")); @@ -570,6 +579,7 @@ pub async fn serve( .collect(), enabled: d.opt_bool("enabled").unwrap_or(true), disabled_tools: Vec::new(), + call_timeout_sec: 0, }; let name = d.str("name"); let res = mcp