From 869bd18bff6cd2ada6db3e7af8024e058668710b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:47:58 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2036=20:=20appels=20d'outils=20MCP=20du=20t?= =?UTF-8?q?our=20de=20la=20fa=C3=A7ade=20par=20son=20pool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...igration-lot36-appels-mcp-locaux-design.md | 56 +++++++++ internal/loki/facade_mcp.go | 9 +- rust/loki-rs/src/chat/generate.rs | 2 + rust/loki-rs/src/chat/host.rs | 41 ++++++- rust/loki-rs/src/main.rs | 12 +- rust/loki-rs/tests/common/fakego.rs | 1 + rust/loki-rs/tests/lot6.rs | 111 +++++++++++++++++- 8 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md diff --git a/README.md b/README.md index 29a3e38..151d272 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | | `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go | -| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | +| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go | | `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go | | `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go | | `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md new file mode 100644 index 0000000..04aa311 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md @@ -0,0 +1,56 @@ +# Lot 36 — Appels d'outils MCP sans détour par le Go + +## Contexte + +Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions +stdio et HTTP, registre des outils exposés. Le tour de la façade, lui, +faisait encore exécuter un appel `mcp__*` par le Go +(`/internal/v1/tool/exec`). + +Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade +(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux +allers-retours locaux pour revenir au même pool. + +Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) : + +- ni capture ; +- ni aperçu du navigateur ; +- ni image relayée. + +Seul le texte revient. + +## Le lot + +- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une + fois dans `main.rs`). +- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call` + directement. Le résultat est celui de la route interne : + - texte aplati et coupé ; + - « [l'outil a renvoyé une erreur] » ; + - « [ok] (aucune sortie) » ; + - une reconnexion si la session est morte, puis « [erreur MCP] … ». +- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route + interne. Elle est lue une fois (`/internal/v1/auth`) et retenue. +- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go, + qui l'exécute avec son propre pool. + +Le Go garde la route interne pour les tours qu'il exécute encore : + +- sous-agents ; +- tâches IA ; +- discussion rendue au Go. + +## Tests + +`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux +serveur MCP stdio. Un tour de la façade appelle : + +- un outil qui répond ; +- un outil dont le serveur meurt. + +Le test vérifie : + +- aucun appel au Go ; +- le résultat de chaque outil tel que le modèle le reçoit ; +- l'erreur après une reconnexion ; +- la version du Go annoncée à cette reconnexion. diff --git a/internal/loki/facade_mcp.go b/internal/loki/facade_mcp.go index b4fa329..f451b48 100644 --- a/internal/loki/facade_mcp.go +++ b/internal/loki/facade_mcp.go @@ -14,10 +14,11 @@ import ( // de la migration ; expérimental, le Go reste le défaut) : configuration, // catalogue, état et pool de sessions (rust/loki-rs/src/mcp). // -// Un seul pool : celui de la façade. Le Go, qui exécute encore les outils de -// l'agent, lui demande les outils MCP à annoncer au modèle et leurs appels -// (/internal/facade/v1/mcp/*), quelle que soit la conversation qui tourne ; -// son propre pool reste vide. +// Un seul pool : celui de la façade. Le Go lui demande les outils MCP à +// annoncer au modèle, et leurs appels pour les tours qu'il exécute encore +// (sous-agents, tâches IA, discussion rendue au Go) via +// /internal/facade/v1/mcp/* ; son propre pool reste vide. Les tours de la +// façade appellent son pool sans passer par ici (lot 36). const facadeMCPEnv = "LOKI_MCP" diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index de083c7..d449e9e 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -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(), } } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 71f1d2e..5b082a1 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -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, /// 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>, + /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue + /// une fois. + pub go_version: tokio::sync::OnceCell, +} + +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, 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 { diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 942912f..a9bbe2f 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -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>, ) -> Result, 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), diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 47704a0..7cacc4d 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -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 diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index b651dbd..4414264 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -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, 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, + Arc, + 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 = 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; +}