mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
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:
20 files changed
+3670
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user