Lot 20 : serveurs MCP tenus par la façade (LOKI_MCP=rust)

La façade tient les serveurs MCP quand LOKI_MCP=rust (expérimental ; le
Go reste le défaut) : configuration (state/mcp), catalogue embarqué (le
même fichier que le Go, copié dans l'étape Rust de l'image), état et
routes /api/mcp*. Un client MCP en Rust (mcp/client.rs) parle JSON-RPC
sur stdio et en Streamable HTTP (réponses JSON ou SSE, session suivie,
en-têtes configurés, ping du serveur servi). Le pool suit mcp_client.go :
session gardée entre les tours, connexions parallèles et partagées,
session invalidée pendant sa connexion jamais republiée, délais npx/uvx,
appel borné avec une reconnexion, sortie plafonnée à la fenêtre.

Un seul pool, celui de la façade : le Go, qui exécute encore les outils
de l'agent, lui demande les outils MCP, leurs appels et le
pré-chauffage (/internal/facade/v1/mcp/*, facade_mcp.go).

Parité : parity_mcp_test.go tire du Go les fonctions pures, seize
mutations de configuration, et un vrai serveur du SDK MCP Go (onze
outils) : ce qu'il met sur le fil et ce que le client Go en tire (état,
outils annoncés, appels). tests/mcp.rs le rejoue contre un faux serveur
qui resert ces réponses, et vérifie stdio, reconnexion, routes, catalogue
et session invalidée de bout en bout ; TestFacadeMCP côté Go.

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-15 19:56:11 +00:00
1 parent 3fc36a3acc
commit 090f15615e
20 files changed
+3670 -2

No files matched your search

+3
View File
@@ -48,6 +48,8 @@ pub struct Ctx {
pub system: Option<std::sync::Arc<crate::system::System>>,
/// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
pub projects: bool,
/// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`).
pub mcp: Option<std::sync::Arc<crate::mcp::Mcp>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -67,6 +69,7 @@ impl Ctx {
|| self.engine_ctl.is_some()
|| self.system.is_some()
|| self.projects
|| self.mcp.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+1
View File
@@ -25,6 +25,7 @@ pub mod engstatus;
pub mod gguf;
pub mod gojson;
pub mod hf;
pub mod mcp;
pub mod memory;
pub mod models;
pub mod oai;
+7 -1
View File
@@ -156,6 +156,9 @@ fn serve() -> ExitCode {
let system_rust = held_by_facade("LOKI_SYSTEM");
// LOKI_PROJECTS=rust : le hub des projets.
let projects_rust = held_by_facade("LOKI_PROJECTS");
// LOKI_MCP=rust : les serveurs MCP (le Go demande alors à la façade
// les outils MCP et leurs appels).
let mcp_rust = held_by_facade("LOKI_MCP");
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -185,6 +188,7 @@ fn serve() -> ExitCode {
("LOKI_ENGINE", engine_rust),
("LOKI_SYSTEM", system_rust),
("LOKI_PROJECTS", projects_rust),
("LOKI_MCP", mcp_rust),
];
for (var, on) in held {
if on {
@@ -284,11 +288,13 @@ 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))),
mem: mem_rust
.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home.clone()))),
presets: presets_rust,
engine_ctl,
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
projects: projects_rust,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
+440
View File
@@ -0,0 +1,440 @@
//! Client MCP (Model Context Protocol) : JSON-RPC 2.0 sur deux transports,
//! comme le SDK Go qu'utilisait `mcp_client.go` :
//!
//! - stdio : un processus local, un message JSON par ligne sur stdin/stdout ;
//! - Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
//! session suivie par l'en-tête `Mcp-Session-Id`.
//!
//! La session s'ouvre par `initialize` puis `notifications/initialized` ; le
//! client ne sert que `tools/list` (première page, comme le Go) et
//! `tools/call`. Une requête du serveur (`ping`) reçoit une réponse vide, les
//! autres « méthode inconnue ».
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin};
use tokio::sync::oneshot;
use super::config::ServerConfig;
/// Version du protocole demandée (celle du SDK Go 1.6).
pub const PROTOCOL_VERSION: &str = "2025-11-25";
type Pending = Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>>;
struct Stdio {
stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
pending: Pending,
child: tokio::sync::Mutex<Option<Child>>,
closed: Arc<AtomicBool>,
}
struct Http {
client: reqwest::Client,
url: String,
headers: BTreeMap<String, String>,
session_id: Mutex<Option<String>>,
protocol: Mutex<Option<String>>,
}
enum Transport {
Stdio(Stdio),
Http(Http),
}
/// Une session MCP ouverte.
pub struct Session {
transport: Transport,
next_id: AtomicI64,
}
/// Le message d'erreur d'`exec` du Go pour un binaire introuvable.
fn spawn_error(cmd: &str, e: &std::io::Error) -> String {
if e.kind() == std::io::ErrorKind::NotFound {
if cmd.contains('/') {
return format!("fork/exec {cmd}: no such file or directory");
}
return format!("exec: \"{cmd}\": executable file not found in $PATH");
}
e.to_string()
}
/// Message d'une erreur JSON-RPC (`{code, message}`).
fn rpc_error(method: &str, err: &Value) -> String {
let msg = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("erreur JSON-RPC");
format!("calling \"{method}\": {msg}")
}
impl Session {
/// Ouvre la session : processus ou point d'accès HTTP, puis la poignée de
/// main. `version` : celle annoncée dans `clientInfo`.
pub async fn connect(cfg: &ServerConfig, version: &str) -> Result<Session, String> {
let transport = match cfg.transport() {
"stdio" => Transport::Stdio(spawn(cfg)?),
"http" => {
crate::chat::engine::install_crypto();
Transport::Http(Http {
client: reqwest::Client::builder()
.build()
.map_err(|e| e.to_string())?,
url: cfg.url.trim().to_string(),
headers: cfg.headers.clone(),
session_id: Mutex::new(None),
protocol: Mutex::new(None),
})
}
_ => return Err("serveur MCP mal configuré (ni command ni url)".into()),
};
let s = Session {
transport,
next_id: AtomicI64::new(1),
};
let init = s
.request(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": { "name": "loki", "version": version },
}),
)
.await;
let init = match init {
Ok(v) => v,
Err(e) => {
s.close().await;
return Err(e);
}
};
if let Transport::Http(h) = &s.transport {
let v = init
.get("protocolVersion")
.and_then(Value::as_str)
.unwrap_or(PROTOCOL_VERSION)
.to_string();
*h.protocol.lock().unwrap_or_else(|e| e.into_inner()) = Some(v);
}
if let Err(e) = s.notify("notifications/initialized", json!({})).await {
s.close().await;
return Err(e);
}
Ok(s)
}
/// `tools/list` (première page, comme le client Go).
pub async fn list_tools(&self) -> Result<Vec<Value>, String> {
let r = self.request("tools/list", json!({})).await?;
Ok(r.get("tools")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
/// `tools/call` : le résultat brut (`content`, `structuredContent`,
/// `isError`).
pub async fn call_tool(&self, name: &str, args: &Map<String, Value>) -> Result<Value, String> {
self.request(
"tools/call",
json!({ "name": name, "arguments": Value::Object(args.clone()) }),
)
.await
}
/// La session est-elle tombée (processus mort) ?
pub fn is_closed(&self) -> bool {
match &self.transport {
Transport::Stdio(s) => s.closed.load(Ordering::SeqCst),
Transport::Http(_) => false,
}
}
/// Ferme la session : processus tué, ou session HTTP terminée.
pub async fn close(&self) {
match &self.transport {
Transport::Stdio(s) => {
s.closed.store(true, Ordering::SeqCst);
if let Some(mut c) = s.child.lock().await.take() {
let _ = c.kill().await;
}
}
Transport::Http(h) => {
let sid = h
.session_id
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(sid) = sid {
let mut rq = h.client.delete(&h.url).header("Mcp-Session-Id", sid);
for (k, v) in &h.headers {
rq = rq.header(k.as_str(), v.as_str());
}
let _ =
tokio::time::timeout(std::time::Duration::from_secs(5), rq.send()).await;
}
}
}
}
async fn request(&self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
let resp = match &self.transport {
Transport::Stdio(s) => s.request(id, method, &msg).await?,
Transport::Http(h) => h.request(id, method, &msg).await?,
};
if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
return Err(rpc_error(method, err));
}
Ok(resp.get("result").cloned().unwrap_or(Value::Null))
}
async fn notify(&self, method: &str, params: Value) -> Result<(), String> {
let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params });
match &self.transport {
Transport::Stdio(s) => s.write(&msg).await,
Transport::Http(h) => h.post(&msg).await.map(|_| ()),
}
}
}
impl Drop for Session {
fn drop(&mut self) {
if let Transport::Stdio(s) = &self.transport {
s.closed.store(true, Ordering::SeqCst);
if let Ok(mut g) = s.child.try_lock()
&& let Some(c) = g.as_mut()
{
let _ = c.start_kill();
}
}
}
}
// ---------------------------------------------------------------------------
// stdio
// ---------------------------------------------------------------------------
fn spawn(cfg: &ServerConfig) -> Result<Stdio, String> {
let mut cmd = tokio::process::Command::new(&cfg.command);
cmd.args(&cfg.args)
.envs(&cfg.env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| spawn_error(&cfg.command, &e))?;
let stdin = Arc::new(tokio::sync::Mutex::new(
child.stdin.take().ok_or("stdin indisponible")?,
));
let stdout = child.stdout.take().ok_or("stdout indisponible")?;
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
let closed = Arc::new(AtomicBool::new(false));
let (p2, c2, w2) = (pending.clone(), closed.clone(), stdin.clone());
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue;
};
if let Some(reply) = dispatch(v, &p2) {
let mut b = serde_json::to_vec(&reply).unwrap_or_default();
b.push(b'\n');
let mut w = w2.lock().await;
let _ = w.write_all(&b).await;
let _ = w.flush().await;
}
}
c2.store(true, Ordering::SeqCst);
// Les appels en attente échouent : leur émetteur disparaît.
p2.lock().unwrap_or_else(|e| e.into_inner()).clear();
});
Ok(Stdio {
stdin,
pending,
child: tokio::sync::Mutex::new(Some(child)),
closed,
})
}
/// Une ligne lue : réponse à un appel en attente (rien à renvoyer), requête
/// du serveur (la réponse à lui écrire), ou notification (ignorée).
fn dispatch(v: Value, pending: &Pending) -> Option<Value> {
let has_method = v.get("method").is_some();
match (v.get("id"), has_method) {
(Some(id), false) => {
if let Some(n) = id.as_i64()
&& let Some(tx) = pending.lock().unwrap_or_else(|e| e.into_inner()).remove(&n)
{
let _ = tx.send(v);
}
None
}
(Some(id), true) => Some(if v.get("method").and_then(Value::as_str) == Some("ping") {
json!({ "jsonrpc": "2.0", "id": id, "result": {} })
} else {
json!({ "jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": "method not found" } })
}),
_ => None,
}
}
impl Stdio {
async fn write(&self, msg: &Value) -> Result<(), String> {
if self.closed.load(Ordering::SeqCst) {
return Err("connection closed".into());
}
let mut line = serde_json::to_vec(msg).map_err(|e| e.to_string())?;
line.push(b'\n');
let mut w = self.stdin.lock().await;
w.write_all(&line).await.map_err(|e| e.to_string())?;
w.flush().await.map_err(|e| e.to_string())
}
async fn request(&self, id: i64, method: &str, msg: &Value) -> Result<Value, String> {
let (tx, rx) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, tx);
if let Err(e) = self.write(msg).await {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
return Err(format!("calling \"{method}\": {e}"));
}
rx.await
.map_err(|_| format!("calling \"{method}\": connection closed"))
}
}
// ---------------------------------------------------------------------------
// Streamable HTTP
// ---------------------------------------------------------------------------
impl Http {
fn builder(&self, msg: &Value) -> reqwest::RequestBuilder {
let mut rq = self
.client
.post(&self.url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(serde_json::to_vec(msg).unwrap_or_default());
if let Some(sid) = self
.session_id
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
rq = rq.header("Mcp-Session-Id", sid);
}
if let Some(p) = self
.protocol
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
rq = rq.header("MCP-Protocol-Version", p);
}
for (k, v) in &self.headers {
rq = rq.header(k.as_str(), v.as_str());
}
rq
}
async fn post(&self, msg: &Value) -> Result<reqwest::Response, String> {
let resp = self.builder(msg).send().await.map_err(|e| e.to_string())?;
if let Some(sid) = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
{
*self.session_id.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string());
}
if !resp.status().is_success() {
let st = resp.status();
let body = resp.text().await.unwrap_or_default();
let body = body.trim();
return Err(if body.is_empty() {
st.to_string()
} else {
format!("{st}: {body}")
});
}
Ok(resp)
}
async fn request(&self, id: i64, method: &str, msg: &Value) -> Result<Value, String> {
let resp = self
.post(msg)
.await
.map_err(|e| format!("calling \"{method}\": {e}"))?;
let ctype = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let body = resp
.text()
.await
.map_err(|e| format!("calling \"{method}\": {e}"))?;
let mut candidates: Vec<Value> = Vec::new();
if ctype.starts_with("text/event-stream") {
let mut data = String::new();
for line in body.lines().chain(std::iter::once("")) {
if line.is_empty() {
if !data.is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(&data) {
candidates.push(v);
}
data.clear();
}
} else if let Some(d) = line.strip_prefix("data:") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(d.strip_prefix(' ').unwrap_or(d));
}
}
} else if let Ok(v) = serde_json::from_str::<Value>(&body) {
match v {
Value::Array(a) => candidates.extend(a),
v => candidates.push(v),
}
}
candidates
.into_iter()
.find(|v| v.get("id").and_then(Value::as_i64) == Some(id) && v.get("method").is_none())
.ok_or_else(|| format!("calling \"{method}\": réponse absente"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erreurs_comme_go() {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
spawn_error("bidon", &e),
"exec: \"bidon\": executable file not found in $PATH"
);
assert_eq!(
rpc_error(
"tools/call",
&json!({"code": -32602, "message": "outil inconnu"})
),
"calling \"tools/call\": outil inconnu"
);
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Configuration des serveurs MCP (`mcp_config.go`) et fonctions pures du
//! client (`mcp_client.go`) : nommage des outils, schémas, libellés, ligne du
//! préambule, délais.
use std::collections::BTreeMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
/// `MCPServerConfig` : un seul transport renseigné, `command` (stdio) ou
/// `url` (Streamable HTTP).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ServerConfig {
#[serde(skip_serializing_if = "String::is_empty")]
pub command: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub url: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub enabled: bool,
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
command: String::new(),
args: Vec::new(),
env: BTreeMap::new(),
url: String::new(),
headers: BTreeMap::new(),
enabled: true,
disabled_tools: Vec::new(),
}
}
}
/// Lecture tolérante : `enabled` vaut vrai quand la clé est absente (fichiers
/// à la Claude Desktop), les champs mal typés restent vides.
impl<'de> Deserialize<'de> for ServerConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Map::<String, Value>::deserialize(d)?;
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let list = |k: &str| -> Vec<String> {
v.get(k)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let map = |k: &str| -> BTreeMap<String, String> {
v.get(k)
.and_then(Value::as_object)
.map(|o| {
o.iter()
.filter_map(|(k, x)| x.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
};
Ok(ServerConfig {
command: s("command"),
args: list("args"),
env: map("env"),
url: s("url"),
headers: map("headers"),
enabled: match v.get("enabled") {
None => true,
Some(x) => x.as_bool().unwrap_or(false),
},
disabled_tools: list("disabledTools"),
})
}
}
impl ServerConfig {
/// `Transport` : « stdio », « http » ou « » (mal configuré).
pub fn transport(&self) -> &'static str {
if !self.command.trim().is_empty() {
"stdio"
} else if !self.url.trim().is_empty() {
"http"
} else {
""
}
}
/// `Validate` : exactement un transport.
pub fn validate(&self) -> Result<(), String> {
let (cmd, url) = (!self.command.trim().is_empty(), !self.url.trim().is_empty());
match (cmd, url) {
(true, true) => Err(
"un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)".into(),
),
(false, false) => {
Err("un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)".into())
}
_ => Ok(()),
}
}
/// `ToolDisabled`.
pub fn tool_disabled(&self, tool: &str) -> bool {
self.disabled_tools.iter().any(|t| t == tool)
}
}
/// Les serveurs déclarés (`state/mcp`), triés par nom.
pub type Servers = BTreeMap<String, ServerConfig>;
/// `loadMCPConfigLocked` : illisible ou absent → aucun serveur.
pub fn decode_servers(raw: Option<&[u8]>) -> Servers {
raw.and_then(|b| serde_json::from_slice::<Option<Servers>>(b).ok())
.flatten()
.unwrap_or_default()
}
/// `SetMCPServer` (sans l'invalidation du pool) : nom contrôlé, transport
/// validé.
pub fn set_server(servers: &mut Servers, name: &str, cfg: ServerConfig) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("nom de serveur vide".into());
}
if name.contains("__") {
return Err("le nom ne peut pas contenir '__' (réservé au namespacing des outils)".into());
}
cfg.validate()?;
servers.insert(name.to_string(), cfg);
Ok(())
}
fn unknown(name: &str) -> String {
format!("serveur MCP inconnu: {name}")
}
/// `DeleteMCPServer`.
pub fn delete_server(servers: &mut Servers, name: &str) -> Result<(), String> {
servers
.remove(name)
.map(|_| ())
.ok_or_else(|| unknown(name))
}
/// `SetMCPServerEnabled`.
pub fn set_enabled(servers: &mut Servers, name: &str, on: bool) -> Result<(), String> {
let cfg = servers.get_mut(name).ok_or_else(|| unknown(name))?;
cfg.enabled = on;
Ok(())
}
/// `SetMCPToolEnabled` : la liste reconstruite sans l'outil, puis l'outil
/// ajouté en fin s'il est masqué.
pub fn set_tool_enabled(
servers: &mut Servers,
server: &str,
tool: &str,
on: bool,
) -> Result<(), String> {
let cfg = servers.get_mut(server).ok_or_else(|| unknown(server))?;
cfg.disabled_tools.retain(|t| t != tool);
if !on {
cfg.disabled_tools.push(tool.to_string());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Fonctions pures du client
// ---------------------------------------------------------------------------
/// `mcpSanitize` : `[^a-zA-Z0-9_-]+` → `_`, puis `_` retirés aux bords.
pub fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_run = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
out.push(c);
in_run = false;
} else if !in_run {
out.push('_');
in_run = true;
}
}
out.trim_matches('_').to_string()
}
/// `mcpExposedName` : `mcp__<serveur>__<outil>`.
pub fn exposed_name(server: &str, tool: &str) -> String {
format!("mcp__{}__{}", sanitize(server), sanitize(tool))
}
/// `isMCPTool`.
pub fn is_mcp_tool(name: &str) -> bool {
name.starts_with("mcp__")
}
/// `mcpNormalizeSchema` : un objet tel quel, sinon un objet sans propriété.
pub fn normalize_schema(schema: Option<&Value>) -> Value {
match schema {
Some(Value::Object(m)) => Value::Object(m.clone()),
_ => json!({ "type": "object", "properties": {} }),
}
}
/// `mcpArgLabel` : une clé parlante, sinon le JSON des arguments (120 runes).
pub fn arg_label(args: &Map<String, Value>) -> String {
if args.is_empty() {
return String::new();
}
for k in ["path", "file", "query", "command", "url", "name"] {
if let Some(Value::String(v)) = args.get(k)
&& !v.is_empty()
{
return v.clone();
}
}
let s = crate::gojson::marshal(args);
if s.chars().count() > 120 {
return s.chars().take(120).collect::<String>() + "…";
}
s
}
/// `mcpTagDescription`.
pub fn tag_description(server: &str, desc: &str) -> String {
format!("[MCP: {server}] {desc}")
}
/// `mcpToolServer` : le serveur d'origine d'un outil annoncé, d'après le
/// préfixe de sa description (la première coupure dont la forme assainie
/// figure bien dans le nom de l'outil).
pub fn tool_server(name: &str, description: &str) -> Option<String> {
if !name.starts_with("mcp__") {
return None;
}
let rest = description.strip_prefix("[MCP: ")?;
let mut i = 0;
loop {
let j = rest[i..].find("] ")?;
let server = &rest[..i + j];
if name.starts_with(&format!("mcp__{}__", sanitize(server))) {
return Some(server.to_string());
}
i += j + 1;
}
}
/// `mcpPromptLine` : les serveurs des outils annoncés ce tour-ci, et leur
/// nombre d'outils. `tools` : (nom, description).
pub fn prompt_line(tools: &[(String, String)]) -> String {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for (n, d) in tools {
if let Some(s) = tool_server(n, d) {
*counts.entry(s).or_default() += 1;
}
}
if counts.is_empty() {
return String::new();
}
let mut names: Vec<String> = counts.iter().map(|(s, n)| format!("{s} ({n})")).collect();
names.sort();
format!(
"\n\nMCP servers connected (their tools are prefixed mcp__<server>__<tool>): {}.",
names.join(", ")
)
}
/// `mcpConnectTimeout`.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier
/// lancement.
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
/// `.cmd`.
fn launcher(cfg: &ServerConfig) -> String {
let t = cfg.command.trim_end_matches('/');
let base = if t.is_empty() {
if cfg.command.is_empty() { "." } else { "/" }
} else {
t.rsplit('/').next().unwrap_or(t)
};
let base = base.to_lowercase();
let b = base.strip_suffix(".exe").unwrap_or(&base);
b.strip_suffix(".cmd").unwrap_or(b).to_string()
}
/// `mcpTimeoutFor`.
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
match launcher(cfg).as_str() {
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
_ => CONNECT_TIMEOUT,
}
}
/// `mcpFriendlyErr` pour un délai dépassé.
pub fn timeout_error(cfg: &ServerConfig) -> 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()
}
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
/// 12 000.
pub fn output_cap(ctx: i64) -> usize {
12000.max(ctx.max(0) as usize)
}
/// `ctxWindow` : `CTX` de la configuration, 32 768 à défaut.
pub fn ctx_window(cfg: &std::collections::HashMap<String, String>) -> i64 {
cfg.get("CTX")
.and_then(|v| v.parse::<i64>().ok())
.filter(|n| *n > 0)
.unwrap_or(32768)
}
/// Le texte d'un résultat d'outil, tronqué au plafond (en runes).
pub fn cap_output(out: String, lim: usize) -> String {
let n = out.chars().count();
if n <= lim {
return out;
}
let kept: String = out.chars().take(lim).collect();
format!("{kept}\n…[tronqué : {n} caractères au total, {lim} gardés]")
}
/// `flattenMCPContent` : blocs texte concaténés, images et sons résumés, les
/// autres rendus en JSON ; le contenu structuré en JSON indenté s'il n'y a pas
/// de texte.
pub fn flatten_content(result: &Value) -> String {
let mut parts: Vec<String> = Vec::new();
for c in result
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let kind = c.get("type").and_then(Value::as_str).unwrap_or("");
let mime = c.get("mimeType").and_then(Value::as_str).unwrap_or("");
match kind {
"text" => parts.push(c.get("text").and_then(Value::as_str).unwrap_or("").into()),
"image" => parts.push(format!("[image {mime}]")),
"audio" => parts.push(format!("[audio {mime}]")),
_ => parts.push(wire_content_json(c)),
}
}
let text = parts.join("\n").trim().to_string();
if text.is_empty()
&& let Some(sc) = result.get("structuredContent").filter(|v| !v.is_null())
{
return String::from_utf8(crate::chatx::marshal_indent(sc)).unwrap_or_default();
}
text
}
/// `json.Marshal` d'un contenu « autre » par le SDK Go (`wireContent`) :
/// champs dans l'ordre de sa struct, vides omis.
fn wire_content_json(c: &Value) -> String {
let order = [
"type",
"text",
"mimeType",
"data",
"resource",
"uri",
"name",
"title",
"description",
"size",
"_meta",
"annotations",
"icons",
"id",
"input",
"toolUseId",
"content",
"structuredContent",
"isError",
];
let mut out = String::from("{");
let mut first = true;
for k in order {
let Some(v) = c.get(k) else { continue };
let empty = match v {
Value::Null => true,
Value::String(s) => s.is_empty(),
Value::Bool(b) => !b,
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty() && k != "resource",
Value::Number(_) => false,
};
if empty && k != "type" {
continue;
}
if !first {
out.push(',');
}
first = false;
out.push_str(&crate::gojson::marshal(k));
out.push(':');
if k == "resource" {
out.push_str(&resource_contents_json(v));
} else {
out.push_str(&crate::gojson::marshal(v));
}
}
out.push('}');
out
}
/// `ResourceContents` du SDK Go : `uri` toujours, puis `mimeType`, `text`,
/// `blob`, `_meta` s'ils sont renseignés.
fn resource_contents_json(v: &Value) -> String {
let mut out = String::from("{");
let mut first = true;
for (k, always) in [
("uri", true),
("mimeType", false),
("text", false),
("blob", false),
("_meta", false),
] {
let x = v.get(k).cloned().unwrap_or(Value::Null);
let empty = match &x {
Value::Null => true,
Value::String(s) => s.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
};
if empty && !always {
continue;
}
if !first {
out.push(',');
}
first = false;
out.push_str(&crate::gojson::marshal(k));
out.push(':');
let x = if x.is_null() && always { json!("") } else { x };
out.push_str(&crate::gojson::marshal(&x));
}
out.push('}');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noms() {
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
assert_eq!(exposed_name("a b", "c.d"), "mcp__a_b__c_d");
}
#[test]
fn lanceurs() {
let c = |cmd: &str| ServerConfig {
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);
}
}
+684
View File
@@ -0,0 +1,684 @@
//! Serveurs MCP tenus par la façade (lot 20 de la migration,
//! `LOKI_MCP=rust` ; expérimental, le Go reste le défaut) — port de
//! `mcp_client.go`, `mcp_config.go`, `mcp_catalog.go` et des routes
//! `/api/mcp*` de `web_api.go`.
//!
//! Le pool de sessions vit ici : une session reste ouverte d'un tour à
//! l'autre (un serveur stdio garde son processus), les connexions se font en
//! parallèle, deux appelants simultanés partagent la même tentative. Le Go,
//! qui exécute encore les outils de l'agent, demande à la façade la liste des
//! outils MCP et leurs appels (`/internal/facade/v1/mcp/*`) : un seul pool,
//! quelle que soit la conversation qui tourne.
pub mod client;
pub mod config;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use futures_util::future::join_all;
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode};
use serde_json::{Map, Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
use client::Session;
use config::{ServerConfig, Servers};
/// Clé de la configuration (`state/mcp`).
pub const ST_MCP: &str = "mcp";
/// Chemins de l'API interne de la façade, pour le Go.
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";
/// Issue d'une tentative de connexion.
#[derive(Default)]
struct SlotState {
sess: Option<Arc<Session>>,
tools: Vec<Value>,
err: Option<String>,
}
/// Une entrée du pool, publiée AVANT la connexion : un second appelant attend
/// son issue au lieu de lancer un deuxième processus.
struct Slot {
cfg: ServerConfig,
state: tokio::sync::OnceCell<SlotState>,
}
/// Le pool des sessions MCP et le registre nom exposé → (serveur, outil).
pub struct Mcp {
home: PathBuf,
slots: Mutex<HashMap<String, Arc<Slot>>>,
registry: Mutex<HashMap<String, (String, String)>>,
cfg_lock: tokio::sync::Mutex<()>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl Mcp {
pub fn new(home: PathBuf) -> Self {
Mcp {
home,
slots: Mutex::new(HashMap::new()),
registry: Mutex::new(HashMap::new()),
cfg_lock: tokio::sync::Mutex::new(()),
}
}
/// `LoadMCPConfig`.
pub async fn load(store: &Store) -> Servers {
config::decode_servers(store.get(BK_STATE, ST_MCP).await.ok().flatten().as_deref())
}
async fn save(store: &Store, servers: &Servers) -> Result<(), String> {
store
.put(BK_STATE, ST_MCP, gojson::marshal(servers).into_bytes())
.await
}
/// Une mutation de la configuration, sérialisée, puis l'invalidation du
/// serveur touché.
async fn mutate(
&self,
store: &Store,
name: &str,
f: impl FnOnce(&mut Servers) -> Result<(), String>,
) -> Result<(), String> {
{
let _g = self.cfg_lock.lock().await;
let mut servers = Self::load(store).await;
f(&mut servers)?;
Self::save(store, &servers).await?;
}
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) {
let slot = lock(&self.slots).remove(name);
lock(&self.registry).retain(|_, (server, _)| server != name);
// Une session encore en cours de connexion se refermera d'elle-même en
// constatant qu'elle n'est plus dans le pool.
if let Some(s) = slot
&& let Some(st) = s.state.get()
&& let Some(sess) = &st.sess
{
sess.close().await;
}
}
/// `mcpCloseAll`.
pub async fn close_all(&self) {
let slots: Vec<_> = lock(&self.slots).drain().map(|(_, s)| s).collect();
lock(&self.registry).clear();
for s in slots {
if let Some(st) = s.state.get()
&& let Some(sess) = &st.sess
{
sess.close().await;
}
}
}
/// `ensure` : la session du serveur, connectée ou en erreur.
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
let slot = lock(&self.slots)
.entry(name.to_string())
.or_insert_with(|| {
Arc::new(Slot {
cfg: cfg.clone(),
state: tokio::sync::OnceCell::new(),
})
})
.clone();
slot.state
.get_or_init(|| async {
let cfg = &slot.cfg;
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)),
Ok(Err(e)) => st.err = Some(e),
Ok(Ok((sess, tools))) => {
match tools {
Ok(t) => st.tools = t,
Err(e) => st.err = Some(e),
}
st.sess = Some(Arc::new(sess));
}
}
// Invalidée pendant la connexion : jamais republiée, refermée.
let current = lock(&self.slots)
.get(name)
.is_some_and(|s| Arc::ptr_eq(s, &slot));
if current {
let mut reg = lock(&self.registry);
reg.retain(|_, (server, _)| server != name);
for t in &st.tools {
let tn = t.get("name").and_then(Value::as_str).unwrap_or("");
reg.insert(
config::exposed_name(name, tn),
(name.to_string(), tn.to_string()),
);
}
} else if let Some(sess) = st.sess.take() {
sess.close().await;
}
st
})
.await;
slot
}
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
async fn ensure_all(&self, servers: &Servers, version: &str) {
join_all(
servers
.iter()
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
.map(|(n, c)| self.ensure(n, c, version)),
)
.await;
}
/// `mcpTools` : les outils des serveurs activés, prêts à être annoncés au
/// modèle (forme `Tool` du Go).
pub async fn tools(&self, store: &Store, version: &str) -> Vec<Value> {
let servers = Self::load(store).await;
if servers.is_empty() {
return Vec::new();
}
self.ensure_all(&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 Some(st) = slot.state.get() else { continue };
if st.sess.is_none() {
continue;
}
for t in &st.tools {
let tn = t.get("name").and_then(Value::as_str).unwrap_or("");
if cfg.tool_disabled(tn) {
continue;
}
let mut desc = t.get("description").and_then(Value::as_str).unwrap_or("");
if desc.is_empty() {
desc = t.get("title").and_then(Value::as_str).unwrap_or("");
}
out.push(json!({
"type": "function",
"function": {
"name": config::exposed_name(name, tn),
"description": config::tag_description(name, desc),
"parameters": config::normalize_schema(t.get("inputSchema")),
},
}));
}
}
out
}
/// `mcpCall` : un outil par son nom exposé ; une reconnexion si la
/// session est morte.
pub async fn call(
&self,
store: &Store,
name: &str,
args: &Map<String, Value>,
version: &str,
) -> String {
let Some((server, tool)) = lock(&self.registry).get(name).cloned() else {
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 {
Ok(r) => return r,
Err(e) => e,
};
let servers = Self::load(store).await;
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 {
Ok(r) => return r,
Err(e) => err = e,
}
}
format!("[erreur MCP] {err}")
}
async fn call_once(
&self,
server: &str,
tool: &str,
args: &Map<String, Value>,
cap: usize,
) -> Result<String, String> {
let slot = lock(&self.slots).get(server).cloned();
let sess = slot
.as_ref()
.and_then(|s| s.state.get())
.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))
.await
.map_err(|_| "context deadline exceeded".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}"));
}
if out.trim().is_empty() {
return Ok("[ok] (aucune sortie)".into());
}
Ok(out)
}
/// `MCPStatus` : l'état de chaque serveur, connexion tentée pour les
/// activés.
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
let servers = Self::load(store).await;
self.ensure_all(&servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
let mut st = json!({
"name": name,
"transport": cfg.transport(),
"enabled": cfg.enabled,
"connected": false,
"tools": [],
"disabled": cfg.disabled_tools,
});
for (k, v) in [
("command", json!(cfg.command)),
("args", json!(cfg.args)),
("env", json!(cfg.env)),
("url", json!(cfg.url)),
("headers", json!(cfg.headers)),
] {
let empty = match &v {
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
};
if !empty {
st[k] = v;
}
}
if cfg.enabled && !cfg.transport().is_empty() {
let slot = self.ensure(name, cfg, version).await;
if let Some(s) = slot.state.get() {
if s.sess.is_some() {
st["connected"] = json!(true);
st["tools"] = json!(
s.tools
.iter()
.map(|t| t.get("name").and_then(Value::as_str).unwrap_or(""))
.collect::<Vec<_>>()
);
}
if let Some(e) = &s.err {
st["error"] = json!(e);
}
}
}
out.push(st);
}
out
}
/// `MCPPrewarm` : les sessions ouvertes en tâche de fond, en mode agent
/// seulement (sans lui, aucun outil MCP n'est annoncé).
pub fn prewarm(self: &Arc<Self>, store: Store, version: String) {
let me = self.clone();
tokio::spawn(async move {
if store.get_str(BK_STATE, "agent").await != "1" {
return;
}
let servers = Self::load(&store).await;
if !servers.is_empty() {
me.ensure_all(&servers, &version).await;
}
});
}
/// `MCPCatalog` : le catalogue embarqué, chemins résolus, entrées déjà
/// configurées marquées.
pub async fn catalog(&self, store: &Store) -> Result<Vec<Value>, String> {
let entries = catalog_entries()?;
let configured = Self::load(store).await;
let ws = workspace_dir(&self.home);
let expand = |s: &str| s.replace("{workspace}", &ws);
let mut out = Vec::new();
for e in entries {
let mut e = e.clone();
let obj = e.as_object_mut().ok_or("entrée du catalogue illisible")?;
if let Some(Value::Array(a)) = obj.get_mut("args") {
for x in a.iter_mut() {
if let Value::String(s) = x {
*s = expand(s);
}
}
}
if let Some(Value::Object(m)) = obj.get_mut("env") {
for x in m.values_mut() {
if let Value::String(s) = x {
*s = expand(s);
}
}
}
for k in ["homepage", "runtime"] {
if obj.get(k).and_then(Value::as_str) == Some("") {
obj.remove(k);
}
}
for k in ["args", "secrets"] {
if obj
.get(k)
.and_then(Value::as_array)
.is_some_and(|a| a.is_empty())
{
obj.remove(k);
}
}
if obj
.get("env")
.and_then(Value::as_object)
.is_some_and(|m| m.is_empty())
{
obj.remove("env");
}
let id = obj.get("id").and_then(Value::as_str).unwrap_or("");
let runtime = obj.get("runtime").and_then(Value::as_str).unwrap_or("");
let (installed, runtime_ok) = (configured.contains_key(id), runtime_available(runtime));
obj.insert("installed".into(), json!(installed));
obj.insert("runtimeOk".into(), json!(runtime_ok));
out.push(e);
}
Ok(out)
}
}
/// `workspaceDir` : le dossier de travail de l'agent.
fn workspace_dir(home: &Path) -> String {
crate::paths::go_join(&[&home.to_string_lossy(), "workspace"])
}
/// `mcpRuntimeAvailable`.
fn runtime_available(runtime: &str) -> bool {
match runtime {
"node" => crate::paths::look_path("npx").is_some(),
"python" => crate::paths::look_path("uvx").is_some(),
_ => true,
}
}
/// Le catalogue embarqué (celui du Go, `mcp_catalog.json`).
static CATALOG: LazyLock<Result<Vec<Value>, String>> = LazyLock::new(|| {
let raw = include_str!("../../../../internal/loki/mcp_catalog.json");
let v: Value =
serde_json::from_str(raw).map_err(|e| format!("catalogue MCP illisible : {e}"))?;
Ok(v.get("servers")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|mut e| {
// Les champs du type Go, dans son ordre (le reste est ignoré).
let mut o = Map::new();
for (k, def) in [
("id", json!("")),
("name", json!("")),
("category", json!("")),
("description", json!("")),
("homepage", json!("")),
("runtime", json!("")),
("command", json!("")),
("args", json!([])),
("env", json!({})),
("secrets", json!([])),
] {
o.insert(k.into(), e.get_mut(k).map(Value::take).unwrap_or(def));
}
Value::Object(o)
})
.collect())
});
fn catalog_entries() -> Result<&'static Vec<Value>, String> {
CATALOG.as_ref().map_err(Clone::clone)
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/mcp"
| "/api/mcp/catalog"
| "/api/mcp/save"
| "/api/mcp/delete"
| "/api/mcp/toggle"
| "/api/mcp/tool"
| "/api/mcp/test"
)
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
const SAVE_REQ_TYPE: &str = "mcpSaveReq";
const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#;
const TOGGLE_REQ_TYPE: &str = r#"struct { Name string "json:\"name\""; On bool "json:\"on\"" }"#;
const TOOL_REQ_TYPE: &str =
r#"struct { Name string "json:\"name\""; Tool string "json:\"tool\""; On bool "json:\"on\"" }"#;
async fn read_body(body: Incoming) -> Vec<u8> {
Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default()
}
/// La version du Go (annoncée aux serveurs dans `clientInfo`).
async fn go_version(ctx: &Ctx) -> String {
ctx.internal
.auth()
.await
.map(|i| i.version)
.unwrap_or_default()
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
mcp: &Arc<Mcp>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
let version = if from_go {
go_version(ctx).await
} else {
match ctx.guard(&parts.headers).await {
Ok(v) => v,
Err(refus) => return *refus,
}
};
let store = &ctx.store;
let servers_ok =
|list: Vec<Value>| send_json(StatusCode::OK, &json!({ "ok": true, "servers": list }));
match parts.uri.path() {
"/api/mcp" => servers_ok(mcp.status(store, &version).await),
"/api/mcp/catalog" => match mcp.catalog(store).await {
Ok(c) => send_json(StatusCode::OK, &json!({ "ok": true, "catalog": c })),
Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e),
},
"/api/mcp/save" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(
&raw,
SAVE_REQ_TYPE,
&[
("name", Kind::Str),
("command", Kind::Str),
("args", Kind::StrList),
("env", Kind::StrMap),
("url", Kind::Str),
("headers", Kind::StrMap),
("enabled", Kind::OptBool),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let cfg = ServerConfig {
command: d.str("command").trim().to_string(),
args: d.str_list("args").unwrap_or_default(),
env: d.str_map("env").unwrap_or_default().into_iter().collect(),
url: d.str("url").trim().to_string(),
headers: d
.str_map("headers")
.unwrap_or_default()
.into_iter()
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
};
let name = d.str("name");
let res = mcp
.mutate(store, name.trim(), |s| config::set_server(s, &name, cfg))
.await;
if let Err(e) = res {
return fail(StatusCode::BAD_REQUEST, e);
}
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/delete" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let name = d.str("name");
if let Err(e) = mcp
.mutate(store, &name, |s| config::delete_server(s, &name))
.await
{
return fail(StatusCode::BAD_REQUEST, e);
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/mcp/toggle" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(
&raw,
TOGGLE_REQ_TYPE,
&[("name", Kind::Str), ("on", Kind::Bool)],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let (name, on) = (d.str("name"), d.bool("on"));
if let Err(e) = mcp
.mutate(store, &name, |s| config::set_enabled(s, &name, on))
.await
{
return fail(StatusCode::BAD_REQUEST, e);
}
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/tool" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(
&raw,
TOOL_REQ_TYPE,
&[("name", Kind::Str), ("tool", Kind::Str), ("on", Kind::Bool)],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let (name, tool, on) = (d.str("name"), d.str("tool"), d.bool("on"));
if let Err(e) = mcp
.mutate(store, &name, |s| {
config::set_tool_enabled(s, &name, &tool, on)
})
.await
{
return fail(StatusCode::BAD_REQUEST, e);
}
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/test" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
mcp.invalidate(&d.str("name")).await;
servers_ok(mcp.status(store, &version).await)
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
/// L'API interne de la façade pour le Go (jeton interne vérifié par
/// l'appelant) : outils annoncés, appels, pré-chauffage.
pub async fn serve_internal(ctx: &Ctx, mcp: &Arc<Mcp>, req: Request<Incoming>) -> Response<Body> {
let path = req.uri().path().to_string();
let raw = read_body(req.into_body()).await;
let version = go_version(ctx).await;
match path.as_str() {
INTERNAL_TOOLS => send_json(
StatusCode::OK,
&json!({ "tools": mcp.tools(&ctx.store, &version).await }),
),
INTERNAL_CALL => {
let v: Value = serde_json::from_slice(&raw).unwrap_or_default();
let name = v.get("name").and_then(Value::as_str).unwrap_or("");
let args = v
.get("args")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let out = mcp.call(&ctx.store, name, &args, &version).await;
send_json(StatusCode::OK, &json!({ "result": out }))
}
INTERNAL_PREWARM => {
mcp.prewarm(ctx.store.clone(), version);
send_json(StatusCode::OK, &json!({ "ok": true }))
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
/// Les chemins de l'API interne de ce module.
pub fn is_internal(path: &str) -> bool {
matches!(path, INTERNAL_TOOLS | INTERNAL_CALL | INTERNAL_PREWARM)
}
+15
View File
@@ -87,6 +87,9 @@ pub struct Config {
pub system: Option<Arc<crate::system::System>>,
/// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
pub projects: bool,
/// Serveurs MCP (pool de sessions, configuration, catalogue) tenus par la
/// façade (`LOKI_MCP=rust`) ; `None` : au Go.
pub mcp: Option<Arc<crate::mcp::Mcp>>,
}
struct Proxy {
@@ -124,6 +127,7 @@ pub async fn serve(
engine_ctl: cfg.engine_ctl,
system: cfg.system,
projects: cfg.projects,
mcp: cfg.mcp,
internal_token: cfg.internal_token.clone(),
},
});
@@ -192,6 +196,12 @@ impl Proxy {
d.shutdown_fast().await;
return plain(StatusCode::NO_CONTENT, "");
}
if from_go
&& let Some(m) = &self.ctx.mcp
&& crate::mcp::is_internal(path)
{
return crate::mcp::serve_internal(&self.ctx, m, req).await;
}
// L'API interne du Go n'est jamais joignable de l'extérieur.
if path.starts_with("/internal/") {
return plain(StatusCode::NOT_FOUND, "404 page not found");
@@ -216,6 +226,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(m) = &self.ctx.mcp
&& crate::mcp::handles(path)
{
return crate::mcp::serve(&self.ctx, m, req, from_go).await;
}
if self.ctx.projects && crate::projects_api::handles(path) {
return crate::projects_api::serve(&self.ctx, req, from_go).await;
}