mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
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>
This commit is contained in:
1 parent
2e28ca955e
commit
f4026506ff
3 files changed
+390
-50
No files matched your search
+158
-32
@@ -26,7 +26,7 @@ pub struct ServerConfig {
|
|||||||
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
|
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
|
||||||
pub disabled_tools: Vec<String>,
|
pub disabled_tools: Vec<String>,
|
||||||
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
|
/// `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")]
|
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
|
||||||
pub call_timeout_sec: i64,
|
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
|
/// `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);
|
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
|
||||||
/// `mcpCallTimeout`.
|
/// `mcpTimeoutMaxSec` : borne haute des réglages (1 h).
|
||||||
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
pub const TIMEOUT_MAX_SECS: i64 = 3600;
|
||||||
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
|
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
|
||||||
/// config.
|
/// config ; il suit la borne des réglages.
|
||||||
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
|
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::<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.
|
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
|
||||||
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
|
pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map<String, Value>) -> Duration {
|
||||||
let mut d = CALL_TIMEOUT;
|
let mut d = tm.call();
|
||||||
if cfg.call_timeout_sec > 0 {
|
if cfg.call_timeout_sec > 0 {
|
||||||
d = Duration::from_secs(cfg.call_timeout_sec as u64);
|
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()
|
b.strip_suffix(".cmd").unwrap_or(b).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `mcpTimeoutFor`.
|
/// `mcpTimeoutFor` : le réglage global, au moins [`CONNECT_TIMEOUT_COLD`]
|
||||||
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
|
/// pour npx et uvx.
|
||||||
|
pub fn timeout_for(cfg: &ServerConfig, tm: &Timeouts) -> Duration {
|
||||||
match launcher(cfg).as_str() {
|
match launcher(cfg).as_str() {
|
||||||
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
|
"npx" | "uvx" => tm.connect().max(CONNECT_TIMEOUT_COLD),
|
||||||
_ => CONNECT_TIMEOUT,
|
_ => tm.connect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `mcpFriendlyErr` pour un délai dépassé.
|
/// `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);
|
let l = launcher(cfg);
|
||||||
if l == "npx" || l == "uvx" {
|
if l == "npx" || l == "uvx" {
|
||||||
return format!(
|
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é — {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
|
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
|
||||||
@@ -534,20 +624,24 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let s = Duration::from_secs;
|
let s = Duration::from_secs;
|
||||||
let none = Map::new();
|
let none = Map::new();
|
||||||
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
|
let def = Timeouts::default();
|
||||||
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
|
let ct = |c: &ServerConfig, a: &Map<String, Value>| call_timeout_for(c, &def, a);
|
||||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
|
assert_eq!(ct(&cfg(0), &none), s(120));
|
||||||
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
|
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!(
|
assert_eq!(
|
||||||
call_timeout_for(&cfg(0), &args(json!(5000.0))),
|
ct(&cfg(0), &args(json!(86400.0 * 1000.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
|
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!(
|
assert_eq!(
|
||||||
call_deadline_error(s(330)),
|
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)"
|
"[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(),
|
command: cmd.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD);
|
let def = Timeouts::default();
|
||||||
assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD);
|
assert_eq!(timeout_for(&c("/usr/bin/npx"), &def), CONNECT_TIMEOUT_COLD);
|
||||||
assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD);
|
assert_eq!(timeout_for(&c("UVX.EXE"), &def), CONNECT_TIMEOUT_COLD);
|
||||||
assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+96
-16
@@ -29,7 +29,7 @@ use crate::gojson::{self, Kind};
|
|||||||
use crate::proxy::Body;
|
use crate::proxy::Body;
|
||||||
use crate::store::{BK_STATE, Store};
|
use crate::store::{BK_STATE, Store};
|
||||||
use client::Session;
|
use client::Session;
|
||||||
use config::{ServerConfig, Servers};
|
use config::{ServerConfig, Servers, Timeouts};
|
||||||
|
|
||||||
/// Clé de la configuration (`state/mcp`).
|
/// Clé de la configuration (`state/mcp`).
|
||||||
pub const ST_MCP: &str = "mcp";
|
pub const ST_MCP: &str = "mcp";
|
||||||
@@ -108,6 +108,26 @@ impl Mcp {
|
|||||||
Ok(())
|
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
|
/// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils
|
||||||
/// retirés du registre.
|
/// retirés du registre.
|
||||||
pub async fn invalidate(&self, name: &str) {
|
pub async fn invalidate(&self, name: &str) {
|
||||||
@@ -136,8 +156,27 @@ impl Mcp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `ensure` : la session du serveur, connectée ou en erreur.
|
/// `LoadMCPTimeouts` : les délais globaux (`state/mcp_timeouts`).
|
||||||
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
|
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)
|
let slot = lock(&self.slots)
|
||||||
.entry(name.to_string())
|
.entry(name.to_string())
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
@@ -150,14 +189,15 @@ impl Mcp {
|
|||||||
slot.state
|
slot.state
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
let cfg = &slot.cfg;
|
let cfg = &slot.cfg;
|
||||||
|
let tm = Self::timeouts(store).await;
|
||||||
let mut st = SlotState::default();
|
let mut st = SlotState::default();
|
||||||
let work = async {
|
let work = async {
|
||||||
let sess = Session::connect(cfg, version).await?;
|
let sess = Session::connect(cfg, version).await?;
|
||||||
let tools = sess.list_tools().await;
|
let tools = sess.list_tools().await;
|
||||||
Ok::<_, String>((sess, tools))
|
Ok::<_, String>((sess, tools))
|
||||||
};
|
};
|
||||||
match tokio::time::timeout(config::timeout_for(cfg), work).await {
|
match tokio::time::timeout(config::timeout_for(cfg, &tm), work).await {
|
||||||
Err(_) => st.err = Some(config::timeout_error(cfg)),
|
Err(_) => st.err = Some(config::timeout_error(cfg, &tm)),
|
||||||
Ok(Err(e)) => st.err = Some(e),
|
Ok(Err(e)) => st.err = Some(e),
|
||||||
Ok(Ok((sess, tools))) => {
|
Ok(Ok((sess, tools))) => {
|
||||||
match tools {
|
match tools {
|
||||||
@@ -191,12 +231,12 @@ impl Mcp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
|
/// `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(
|
join_all(
|
||||||
servers
|
servers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
|
.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;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -208,13 +248,13 @@ impl Mcp {
|
|||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
self.ensure_all(&servers, version).await;
|
self.ensure_all(store, &servers, version).await;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (name, cfg) in &servers {
|
for (name, cfg) in &servers {
|
||||||
if !cfg.enabled || cfg.transport().is_empty() {
|
if !cfg.enabled || cfg.transport().is_empty() {
|
||||||
continue;
|
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 };
|
let Some(st) = slot.state.get() else { continue };
|
||||||
if st.sess.is_none() {
|
if st.sess.is_none() {
|
||||||
continue;
|
continue;
|
||||||
@@ -255,8 +295,11 @@ impl Mcp {
|
|||||||
};
|
};
|
||||||
let cap = config::output_cap(config::ctx_window(&store.config().await));
|
let cap = config::output_cap(config::ctx_window(&store.config().await));
|
||||||
let servers = Self::load(store).await;
|
let servers = Self::load(store).await;
|
||||||
let timeout =
|
let timeout = config::call_timeout_for(
|
||||||
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
|
&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 {
|
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||||
Ok(r) => return r,
|
Ok(r) => return r,
|
||||||
Err(e) => e,
|
Err(e) => e,
|
||||||
@@ -266,7 +309,7 @@ impl Mcp {
|
|||||||
}
|
}
|
||||||
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
|
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
|
||||||
self.invalidate(&server).await;
|
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 {
|
match self.call_once(&server, &tool, args, cap, timeout).await {
|
||||||
Ok(r) => return r,
|
Ok(r) => return r,
|
||||||
Err(e) => err = e,
|
Err(e) => err = e,
|
||||||
@@ -307,7 +350,7 @@ impl Mcp {
|
|||||||
/// activés.
|
/// activés.
|
||||||
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
|
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
|
||||||
let servers = Self::load(store).await;
|
let servers = Self::load(store).await;
|
||||||
self.ensure_all(&servers, version).await;
|
self.ensure_all(store, &servers, version).await;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (name, cfg) in &servers {
|
for (name, cfg) in &servers {
|
||||||
let mut st = json!({
|
let mut st = json!({
|
||||||
@@ -324,11 +367,13 @@ impl Mcp {
|
|||||||
("env", json!(cfg.env)),
|
("env", json!(cfg.env)),
|
||||||
("url", json!(cfg.url)),
|
("url", json!(cfg.url)),
|
||||||
("headers", json!(cfg.headers)),
|
("headers", json!(cfg.headers)),
|
||||||
|
("callTimeoutSec", json!(cfg.call_timeout_sec)),
|
||||||
] {
|
] {
|
||||||
let empty = match &v {
|
let empty = match &v {
|
||||||
Value::String(s) => s.is_empty(),
|
Value::String(s) => s.is_empty(),
|
||||||
Value::Array(a) => a.is_empty(),
|
Value::Array(a) => a.is_empty(),
|
||||||
Value::Object(o) => o.is_empty(),
|
Value::Object(o) => o.is_empty(),
|
||||||
|
Value::Number(n) => n.as_i64() == Some(0),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if !empty {
|
if !empty {
|
||||||
@@ -336,7 +381,7 @@ impl Mcp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.enabled && !cfg.transport().is_empty() {
|
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 let Some(s) = slot.state.get() {
|
||||||
if s.sess.is_some() {
|
if s.sess.is_some() {
|
||||||
st["connected"] = json!(true);
|
st["connected"] = json!(true);
|
||||||
@@ -367,7 +412,7 @@ impl Mcp {
|
|||||||
}
|
}
|
||||||
let servers = Self::load(&store).await;
|
let servers = Self::load(&store).await;
|
||||||
if !servers.is_empty() {
|
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/toggle"
|
||||||
| "/api/mcp/tool"
|
| "/api/mcp/tool"
|
||||||
| "/api/mcp/test"
|
| "/api/mcp/test"
|
||||||
|
| "/api/mcp/timeouts"
|
||||||
|
| "/api/mcp/timeouts/save"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,11 +609,16 @@ pub async fn serve(
|
|||||||
("url", Kind::Str),
|
("url", Kind::Str),
|
||||||
("headers", Kind::StrMap),
|
("headers", Kind::StrMap),
|
||||||
("enabled", Kind::OptBool),
|
("enabled", Kind::OptBool),
|
||||||
|
("callTimeoutSec", Kind::Int),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if let Some(e) = d.err.clone() {
|
if let Some(e) = d.err.clone() {
|
||||||
return fail(StatusCode::BAD_REQUEST, e);
|
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 {
|
let cfg = ServerConfig {
|
||||||
command: d.str("command").trim().to_string(),
|
command: d.str("command").trim().to_string(),
|
||||||
args: d.str_list("args").unwrap_or_default(),
|
args: d.str_list("args").unwrap_or_default(),
|
||||||
@@ -579,7 +631,7 @@ pub async fn serve(
|
|||||||
.collect(),
|
.collect(),
|
||||||
enabled: d.opt_bool("enabled").unwrap_or(true),
|
enabled: d.opt_bool("enabled").unwrap_or(true),
|
||||||
disabled_tools: Vec::new(),
|
disabled_tools: Vec::new(),
|
||||||
call_timeout_sec: 0,
|
call_timeout_sec,
|
||||||
};
|
};
|
||||||
let name = d.str("name");
|
let name = d.str("name");
|
||||||
let res = mcp
|
let res = mcp
|
||||||
@@ -654,6 +706,34 @@ pub async fn serve(
|
|||||||
mcp.invalidate(&d.str("name")).await;
|
mcp.invalidate(&d.str("name")).await;
|
||||||
servers_ok(mcp.status(store, &version).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"),
|
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-2
@@ -66,17 +66,44 @@ fn parite_des_fonctions_pures() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}");
|
assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}");
|
||||||
}
|
}
|
||||||
|
let def = cf::Timeouts::default();
|
||||||
for (cmd, v) in fx["timeouts"].as_object().unwrap() {
|
for (cmd, v) in fx["timeouts"].as_object().unwrap() {
|
||||||
let cfg = cf::ServerConfig {
|
let cfg = cf::ServerConfig {
|
||||||
command: cmd.clone(),
|
command: cmd.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cf::timeout_for(&cfg).as_secs() as i64,
|
cf::timeout_for(&cfg, &def).as_secs() as i64,
|
||||||
v["secs"].as_i64().unwrap(),
|
v["secs"].as_i64().unwrap(),
|
||||||
"{cmd}"
|
"{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() {
|
for c in fx["flatten"].as_array().unwrap() {
|
||||||
let v: Value = serde_json::from_str(s(&c["in"])).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();
|
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
|
||||||
assert_eq!(stored["web"]["url"], json!(url));
|
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.
|
// Erreurs de la configuration et du JSON.
|
||||||
let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await;
|
let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -506,6 +577,67 @@ async fn routes_de_configuration() {
|
|||||||
pool.close_all().await;
|
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]
|
#[tokio::test]
|
||||||
async fn catalogue_embarque() {
|
async fn catalogue_embarque() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -544,6 +676,8 @@ async fn sans_la_variable_mcp_reste_au_go() {
|
|||||||
"/api/mcp/save",
|
"/api/mcp/save",
|
||||||
"/api/mcp/catalog",
|
"/api/mcp/catalog",
|
||||||
"/api/mcp/test",
|
"/api/mcp/test",
|
||||||
|
"/api/mcp/timeouts",
|
||||||
|
"/api/mcp/timeouts/save",
|
||||||
] {
|
] {
|
||||||
let (_, _, b) = fakego::get(facade, p, &[]).await;
|
let (_, _, b) = fakego::get(facade, p, &[]).await;
|
||||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||||
|
|||||||
Reference in new issue
Block a user