diff --git a/rust/loki-rs/src/mcp/config.rs b/rust/loki-rs/src/mcp/config.rs index 262130e..3f4e7ca 100644 --- a/rust/loki-rs/src/mcp/config.rs +++ b/rust/loki-rs/src/mcp/config.rs @@ -26,7 +26,7 @@ pub struct ServerConfig { #[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`]). + /// secondes ; 0 = réglage global ([`Timeouts::call`]). #[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")] pub call_timeout_sec: i64, } @@ -285,21 +285,107 @@ 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. -pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60); +/// 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"; -/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du +/// `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::(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, args: &Map) -> Duration { - let mut d = CALL_TIMEOUT; +pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map) -> Duration { + let mut d = tm.call(); if cfg.call_timeout_sec > 0 { d = Duration::from_secs(cfg.call_timeout_sec as u64); } @@ -354,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 @@ -534,20 +624,24 @@ mod tests { }; 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)); + let def = Timeouts::default(); + let ct = |c: &ServerConfig, a: &Map| 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!( - 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))), + ct(&cfg(0), &args(json!(86400.0 * 1000.0))), CALL_TIMEOUT_MAX ); - assert_eq!(call_timeout_for(&cfg(30), &none), s(30)); + 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)" @@ -577,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()); } } diff --git a/rust/loki-rs/src/mcp/mod.rs b/rust/loki-rs/src/mcp/mod.rs index 6fd77b1..61314bc 100644 --- a/rust/loki-rs/src/mcp/mod.rs +++ b/rust/loki-rs/src/mcp/mod.rs @@ -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"; @@ -108,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 = { + 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) { @@ -136,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 { + /// `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 { let slot = lock(&self.slots) .entry(name.to_string()) .or_insert_with(|| { @@ -150,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 { @@ -191,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; } @@ -208,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; @@ -255,8 +295,11 @@ impl Mcp { }; let cap = config::output_cap(config::ctx_window(&store.config().await)); let servers = Self::load(store).await; - let timeout = - config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args); + 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, @@ -266,7 +309,7 @@ impl Mcp { } if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) { self.invalidate(&server).await; - self.ensure(&server, cfg, version).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, @@ -307,7 +350,7 @@ impl Mcp { /// activés. pub async fn status(&self, store: &Store, version: &str) -> Vec { 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!({ @@ -324,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 { @@ -336,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); @@ -367,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; } }); } @@ -494,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" ) } @@ -562,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(), @@ -579,7 +631,7 @@ pub async fn serve( .collect(), enabled: d.opt_bool("enabled").unwrap_or(true), disabled_tools: Vec::new(), - call_timeout_sec: 0, + call_timeout_sec, }; let name = d.str("name"); let res = mcp @@ -654,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"), } } diff --git a/rust/loki-rs/tests/mcp.rs b/rust/loki-rs/tests/mcp.rs index 87fd3d5..9bc2e43 100644 --- a/rust/loki-rs/tests/mcp.rs +++ b/rust/loki-rs/tests/mcp.rs @@ -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 = 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| { + 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());