mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 21:18:20 +02:00
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>
This commit is contained in:
1 parent
68dd65cd5a
commit
4c2cf8b4e6
2 files changed
+117
-5
No files matched your search
@@ -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 = 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<String, Value>) -> 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<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`.
|
||||
@@ -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");
|
||||
|
||||
@@ -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<String, Value>,
|
||||
cap: usize,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<String, String> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user