Lot 36 : appels d'outils MCP du tour de la façade par son pool

Quand la façade tient les serveurs MCP (LOKI_MCP=rust), un appel mcp__*
d'un de ses tours partait au Go (/internal/v1/tool/exec), qui le renvoyait
aussitôt à la façade (/internal/facade/v1/mcp/call) : deux allers-retours
pour revenir au même pool. Le Go n'ajoute rien au résultat d'un outil MCP.

ChatShared porte désormais le pool (le même que celui des routes, créé une
fois) ; GoTurnHost::exec_tool l'appelle directement — même résultat que la
route interne, reconnexion comprise. Une reconnexion annonce la version du
Go (/internal/v1/auth), lue une fois et retenue. Sans pool tenu, rien ne
change. Le Go garde la route interne pour les tours qu'il exécute encore.

Test : un tour de la façade contre un faux serveur MCP stdio — rien par le
Go, résultats tels que le modèle les reçoit, erreur après reconnexion,
version du Go annoncée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 03:47:58 +00:00
1 parent 9dfce204bf
commit 869bd18bff
8 files changed
+223 -11

No files matched your search

+2
View File
@@ -1303,6 +1303,8 @@ impl ChatShared {
patterns: super::stream::RetryPatterns::builtin(),
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
prewarm: Default::default(),
mcp: None,
go_version: Default::default(),
}
}
}
+40 -1
View File
@@ -3,7 +3,8 @@
//! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes
//! classées et formulées comme `friendlyLLMError` ;
//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé
//! tenu par le Go) ; les autres par le Go (`/internal/v1/tool/exec`), avec
//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient
//! (lot 36) ; les autres par le Go (`/internal/v1/tool/exec`), avec
//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur,
//! image relayée) ;
//! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ;
@@ -330,6 +331,32 @@ pub struct ChatShared {
pub tpl: Arc<super::tplprobe::TplProbe>,
/// Préchauffage (PREWARM) : celui en vol, capacités des discussions.
pub prewarm: super::prewarm::Prewarm,
/// Le pool MCP, quand la façade le tient (LOKI_MCP=rust) : les appels
/// `mcp__*` du tour y vont directement (lot 36).
pub mcp: Option<Arc<crate::mcp::Mcp>>,
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
/// une fois.
pub go_version: tokio::sync::OnceCell<String>,
}
impl ChatShared {
/// [`Self::go_version`] : `/internal/v1/auth`, retenue une fois lue.
pub async fn go_version(&self) -> String {
if let Some(v) = self.go_version.get() {
return v.clone();
}
let v = self
.store
.internal_json(hyper::Method::GET, "/internal/v1/auth", None)
.await
.ok()
.and_then(|v| v.get("version").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default();
if !v.is_empty() {
let _ = self.go_version.set(v.clone());
}
v
}
}
/// Ce que la compaction sait du tour.
@@ -615,6 +642,18 @@ impl TurnHost for GoTurnHost {
}
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
// Outils MCP, quand la façade tient le pool : ce que ferait le Go
// (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux
// allers-retours. Le Go n'en tire rien d'autre que le texte.
if crate::mcp::config::is_mcp_tool(name)
&& let Some(mcp) = &self.shared.mcp
{
let version = self.shared.go_version().await;
return ToolOutcome {
result: mcp.call(&self.shared.store, name, args, &version).await,
..Default::default()
};
}
// Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go.
if crate::tools::is_local(name) {
let st = crate::tools::GoToolState {
+8 -4
View File
@@ -262,8 +262,11 @@ fn serve() -> ExitCode {
return ExitCode::FAILURE;
}
};
// Un seul pool MCP : celui des routes, et celui des tours de la façade.
let mcp = mcp_rust
.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(engine_paths.home.clone())));
let chat = if chat_rust {
match start_chat(backend, &token, &engine_paths.home).await {
match start_chat(backend, &token, &engine_paths.home, mcp.clone()).await {
Ok(c) => Some(c),
Err(e) => {
eprintln!("[loki-rs] conversation : {e}");
@@ -386,8 +389,7 @@ fn serve() -> ExitCode {
settings: settings_rust,
files,
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
mem: mem_rust
.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home.clone()))),
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
presets: presets_rust,
engine_ctl,
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
@@ -399,7 +401,7 @@ fn serve() -> ExitCode {
tasks,
bench,
tune,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
mcp,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
@@ -510,6 +512,7 @@ async fn start_chat(
backend: SocketAddr,
token: &str,
home: &std::path::Path,
mcp: Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
) -> Result<std::sync::Arc<loki_rs::chat::generate::ChatService>, String> {
use loki_rs::chat::{conv::Conversation, generate::ChatService, host::ChatShared, sessions};
let store = loki_rs::store::Store::new(backend, token.to_string());
@@ -521,6 +524,7 @@ async fn start_chat(
s.load().await?;
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
shared.mcp = mcp;
eprintln!("[loki-rs] conversation tenue par la façade (LOKI_CHAT=go pour la rendre au Go)");
Ok(ChatService::new(
std::sync::Arc::new(s),
+1
View File
@@ -1006,6 +1006,7 @@ pub async fn start_with(
s.load().await.unwrap();
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
shared.mcp = opts.mcp.clone();
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
} else {
None
+110 -1
View File
@@ -667,6 +667,8 @@ async fn tour_complet_par_le_go() {
patterns: RetryPatterns::builtin(),
tpl: Arc::new(loki_rs::chat::tplprobe::TplProbe::new()),
prewarm: Default::default(),
mcp: None,
go_version: Default::default(),
});
let cfg = std::collections::HashMap::new();
let host = GoTurnHost {
@@ -848,6 +850,19 @@ async fn service(
Arc<fakego::FakeGo>,
tokio::sync::oneshot::Sender<()>,
tempfile::TempDir,
) {
service_with(engine_url, None).await
}
/// [`service`], avec le pool MCP de la façade.
async fn service_with(
engine_url: Option<&str>,
mcp: Option<Arc<loki_rs::mcp::Mcp>>,
) -> (
Arc<ChatService>,
Arc<fakego::FakeGo>,
tokio::sync::oneshot::Sender<()>,
tempfile::TempDir,
) {
let (st, go, stop, t) = store().await;
go.set(
@@ -858,7 +873,8 @@ async fn service(
go.set("state", "api_key", "cle-du-moteur");
let sessions = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
sessions.load().await.unwrap();
let shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
let mut shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
shared.mcp = mcp;
(
ChatService::new(Arc::new(sessions), Arc::new(shared)),
go,
@@ -2384,3 +2400,96 @@ async fn bloc_projet_rafraichi_en_plein_tour() {
serde_json::from_value(proj_snap("vivant", 0)).unwrap()
);
}
/// Lot 36 : un outil MCP appelé par un tour de la façade passe par son pool,
/// pas par le Go ; une reconnexion annonce la version du Go.
#[cfg(unix)]
#[tokio::test]
async fn outil_mcp_par_le_pool_de_la_facade() {
use std::os::unix::fs::PermissionsExt;
let call = |id: &str, name: &str| {
sse(&[
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":id,"type":"function","function":{"name":name,"arguments":"{\"q\":1}"}}]}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
])
};
let (url, seen) = fake_engine(vec![
call("m1", "mcp__sh__date"),
call("m2", "mcp__sh__crash"),
answer("il est midi"),
])
.await;
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("faux-mcp");
std::fs::write(
&bin,
r#"#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '%s\n' "$line" >> "$TRACE"
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"sh","version":"1"}}}\n' "$id" ;;
*'"method":"tools/list"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"date","inputSchema":{"type":"object"}},{"name":"crash","inputSchema":{"type":"object"}}]}}\n' "$id" ;;
*'"name":"crash"'*) exit 3 ;;
*'"method":"tools/call"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"midi"}]}}\n' "$id" ;;
esac
done
"#,
)
.unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let trace = dir.path().join("trace");
let pool = Arc::new(loki_rs::mcp::Mcp::new(dir.path().to_path_buf()));
let (svc, go, _stop, _t) = service_with(Some(&url), Some(pool.clone())).await;
let mut servers = loki_rs::mcp::config::Servers::new();
servers.insert(
"sh".into(),
loki_rs::mcp::config::ServerConfig {
command: bin.to_string_lossy().into_owned(),
env: [("TRACE".to_string(), trace.to_string_lossy().into_owned())].into(),
..Default::default()
},
);
go.set("state", "mcp", &loki_rs::gojson::marshal(&servers));
// Les outils annoncés au modèle (ce que le Go demande à la façade) :
// le registre du pool est rempli.
let tools = pool.tools(&svc.shared.store, "annonce").await;
assert_eq!(tools.len(), 2, "{tools:?}");
svc.send(serde_json::json!({"message": "quelle heure ?", "cid": "m"}))
.await
.unwrap();
until_done(&svc, 1).await;
// Rien par le Go.
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 3);
let tool_msg = |i: usize| -> String {
seen[i].1["messages"]
.as_array()
.unwrap()
.iter()
.rfind(|m| m["role"] == "tool")
.map(|m| m["content"].as_str().unwrap_or("").to_string())
.unwrap_or_default()
};
assert_eq!(tool_msg(1), "midi");
let crashed = tool_msg(2);
assert!(
crashed.starts_with("[erreur MCP] calling \"tools/call\""),
"{crashed}"
);
// Première connexion : la version donnée à l'annonce ; reconnexion
// après la chute : celle du Go.
let inits: Vec<String> = std::fs::read_to_string(&trace)
.unwrap()
.lines()
.map(String::from)
.collect();
assert_eq!(inits.len(), 2, "{inits:?}");
assert!(inits[0].contains(r#""version":"annonce""#), "{inits:?}");
assert!(inits[1].contains(r#""version":"0.15.0""#), "{inits:?}");
pool.close_all().await;
}