use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use anyhow::Result;
use sha2::{Digest, Sha256};
use super::{Tool, ToolContext, ToolImage, ToolOutcome, meta};
use crate::entities::profile::ToolId;
use crate::shared::i18n::Locale;
use crate::shared::mcp::{McpConnection, McpToolInfo};
use crate::shared::server::ServerStatus;
#[derive(Debug, Clone, Default)]
pub struct McpSnapshot {
pub tools: Vec<meta::ToolInfo>,
pub servers: Vec<McpServerSnapshot>,
}
#[derive(Debug, Clone)]
pub struct McpServerSnapshot {
pub id: String,
pub status: ServerStatus,
pub tool_count: usize,
pub pending_catalog: bool,
}
pub fn catalog_hash(tools: &[McpToolInfo]) -> String {
let mut sorted: Vec<&McpToolInfo> = tools.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let mut hasher = Sha256::new();
for t in sorted {
hasher.update(t.name.as_bytes());
hasher.update([0]);
hasher.update(t.description.as_bytes());
hasher.update([0]);
hasher.update(t.input_schema.to_string().as_bytes());
hasher.update([0xff]);
}
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
pub const MCP_TOOL_PREFIX: &str = "mcp__";
const MAX_TOOL_ID: usize = 64;
pub fn mcp_tool_id(server: &str, tool: &str) -> ToolId {
let raw = format!("{MCP_TOOL_PREFIX}{server}__{tool}");
let sanitized: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
if sanitized.len() <= MAX_TOOL_ID {
return sanitized;
}
let mut hasher = DefaultHasher::new();
raw.hash(&mut hasher);
let suffix = format!("_{:08x}", (hasher.finish() & 0xffff_ffff) as u32);
let keep = MAX_TOOL_ID - suffix.len();
format!("{}{}", &sanitized[..keep], suffix)
}
fn intern(s: &str) -> &'static str {
static POOL: OnceLock<Mutex<HashSet<&str>>> = OnceLock::new();
let pool = POOL.get_or_init(|| Mutex::new(HashSet::new()));
let mut guard = pool.lock().expect("intern pool poisoned");
if let Some(existing) = guard.get(s) {
return existing;
}
let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
guard.insert(leaked);
leaked
}
pub struct McpTool {
id: ToolId,
remote_name: String,
description: String,
input_schema: serde_json::Value,
label: &'static str,
conn: Arc<McpConnection>,
timeout: Duration,
max_result_chars: usize,
}
impl McpTool {
pub fn new(
server_id: &str,
info: &McpToolInfo,
conn: Arc<McpConnection>,
timeout: Duration,
max_result_chars: usize,
) -> Self {
Self {
id: mcp_tool_id(server_id, &info.name),
remote_name: info.name.clone(),
description: info.description.clone(),
input_schema: info.input_schema.clone(),
label: intern(&info.name),
conn,
timeout,
max_result_chars: max_result_chars.max(1),
}
}
}
fn clip_result(text: &str, max: usize, loc: &Locale) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let clipped: String = text.chars().take(max).collect();
format!("{clipped}… {}", loc.t("tool.mcp.result_truncated"))
}
#[async_trait::async_trait]
impl Tool for McpTool {
fn id(&self) -> ToolId {
self.id.clone()
}
fn description(&self, _loc: &Locale) -> String {
self.description.clone()
}
fn parameters(&self, _loc: &Locale) -> serde_json::Value {
self.input_schema.clone()
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let result = self
.conn
.call_tool(&self.remote_name, args, self.timeout, Some(&ctx.cancel))
.await?;
let text = if result.is_error && result.text.is_empty() {
ctx.loc.t("tool.mcp.error_empty").to_string()
} else {
result.text
};
let (images, withheld): (Vec<ToolImage>, usize) = if ctx.mcp_images {
(
result
.images
.into_iter()
.map(|i| ToolImage {
mime: i.mime,
data: i.data,
entry: None,
})
.collect(),
0,
)
} else {
(Vec::new(), result.images.len())
};
let mut text = clip_result(&text, self.max_result_chars, ctx.loc);
if withheld > 0 {
text.push('\n');
text.push_str(
&ctx.loc
.tf("tool.mcp.images_off", &[("n", &withheld.to_string())]),
);
}
Ok(ToolOutcome::text(text).with_images(images))
}
fn group(&self) -> meta::ToolGroup {
meta::ToolGroup::Plugins
}
fn ui_label(&self) -> &'static str {
self.label
}
fn danger(&self) -> bool {
true
}
fn gate(&self) -> Option<meta::ToolGate> {
Some(meta::ToolGate::Mcp)
}
fn enabled_by_default(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::tools::testkit;
use serde_json::json;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use uuid::Uuid;
#[test]
fn tool_id_sanitizes_and_keeps_short_names() {
assert_eq!(
mcp_tool_id("fs", "read_text_file"),
"mcp__fs__read_text_file"
);
assert_eq!(mcp_tool_id("srv", "a.b/c d"), "mcp__srv__a_b_c_d");
}
#[test]
fn tool_id_truncates_long_names_with_stable_hash_tail() {
let long = "x".repeat(100);
let id1 = mcp_tool_id("server-with-long-id", &long);
assert_eq!(id1.len(), 64);
let id2 = mcp_tool_id("server-with-long-id", &format!("{long}y"));
assert_eq!(id1, mcp_tool_id("server-with-long-id", &long));
assert_ne!(id1, id2);
assert!(id1.starts_with("mcp__server-with-long-id__"));
}
#[test]
fn clip_result_is_char_exact_and_localized() {
use crate::shared::i18n::{Lang, locale};
let ru = locale(Lang::Ru);
assert_eq!(clip_result("привет", 10, ru), "привет");
let clipped = clip_result(&"я".repeat(30), 5, ru);
assert!(clipped.starts_with("яяяяя"));
assert!(clipped.contains("усеч"), "{clipped}");
let en = clip_result(&"a".repeat(30), 5, locale(Lang::En));
assert!(en.contains("truncated"), "{en}");
}
#[test]
fn intern_dedups() {
let a = intern("read_file");
let b = intern("read_file");
assert!(std::ptr::eq(a, b));
}
fn conn_with_call_reply(reply_text: String) -> Arc<McpConnection> {
let (client_io, server_io) = tokio::io::duplex(64 * 1024);
let (client_r, client_w) = tokio::io::split(client_io);
let (server_r, mut server_w) = tokio::io::split(server_io);
tokio::spawn(async move {
let mut lines = BufReader::new(server_r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let Some(id) = msg.get("id").cloned() else {
continue;
};
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": reply_text }],
"isError": false
}});
if server_w
.write_all(format!("{reply}\n").as_bytes())
.await
.is_err()
{
break;
}
}
});
Arc::new(McpConnection::over(client_r, client_w))
}
#[tokio::test]
async fn invoke_calls_server_and_clips_result() {
let conn = conn_with_call_reply("0123456789".repeat(10)); let info = McpToolInfo {
name: "echo".into(),
description: "Echo test tool".into(),
input_schema: json!({ "type": "object" }),
};
let tool = McpTool::new("test", &info, conn, Duration::from_secs(5), 20);
assert_eq!(tool.id(), "mcp__test__echo");
assert_eq!(tool.group(), meta::ToolGroup::Plugins);
assert_eq!(tool.gate(), Some(meta::ToolGate::Mcp));
assert!(!tool.enabled_by_default());
let (_d, _s, ctx) = testkit::ctx_with_storage(Uuid::new_v4());
let out = tool.invoke(&ctx, json!({ "text": "hi" })).await.unwrap();
assert!(out.effects.is_empty());
assert!(out.result.starts_with("01234567890123456789"));
assert!(out.result.contains("усеч"), "{}", out.result);
}
fn conn_with_image_reply() -> Arc<McpConnection> {
let (client_io, server_io) = tokio::io::duplex(64 * 1024);
let (client_r, client_w) = tokio::io::split(client_io);
let (server_r, mut server_w) = tokio::io::split(server_io);
tokio::spawn(async move {
let mut lines = BufReader::new(server_r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let Some(id) = msg.get("id").cloned() else {
continue;
};
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [
{ "type": "text", "text": "Screenshot taken." },
{ "type": "image", "data": "QUJD", "mimeType": "image/png" },
],
"isError": false
}});
if server_w
.write_all(format!("{reply}\n").as_bytes())
.await
.is_err()
{
break;
}
}
});
Arc::new(McpConnection::over(client_r, client_w))
}
#[tokio::test]
async fn the_switch_decides_whether_a_server_image_reaches_the_model() {
let info = McpToolInfo {
name: "screenshot".into(),
description: "Take a screenshot".into(),
input_schema: json!({ "type": "object" }),
};
let tool = McpTool::new(
"test",
&info,
conn_with_image_reply(),
Duration::from_secs(5),
1000,
);
let (_d, _s, mut ctx) = testkit::ctx_with_storage(Uuid::new_v4());
ctx.mcp_images = true;
let out = tool.invoke(&ctx, json!({})).await.unwrap();
assert_eq!(
out.images,
vec![ToolImage {
mime: "image/png".into(),
data: "QUJD".into(),
entry: None,
}]
);
assert_eq!(out.result, "Screenshot taken.");
let tool = McpTool::new(
"test",
&info,
conn_with_image_reply(),
Duration::from_secs(5),
1000,
);
ctx.mcp_images = false;
let out = tool.invoke(&ctx, json!({})).await.unwrap();
assert!(out.images.is_empty());
assert!(
out.result.starts_with("Screenshot taken."),
"the server's own text survives: {}",
out.result
);
let said = ctx.loc.tf("tool.mcp.images_off", &[("n", "1")]);
assert!(
out.result.ends_with(&said),
"the withheld image has to be stated: {}",
out.result
);
}
#[tokio::test]
async fn a_withheld_image_is_still_stated_when_the_text_is_truncated() {
let info = McpToolInfo {
name: "screenshot".into(),
description: "Take a screenshot".into(),
input_schema: json!({ "type": "object" }),
};
let tool = McpTool::new(
"test",
&info,
conn_with_image_reply(),
Duration::from_secs(5),
4,
);
let (_d, _s, mut ctx) = testkit::ctx_with_storage(Uuid::new_v4());
ctx.mcp_images = false;
let out = tool.invoke(&ctx, json!({})).await.unwrap();
assert!(
out.result
.contains(&ctx.loc.t("tool.mcp.result_truncated").to_string()),
"the fixture must actually be truncated: {}",
out.result
);
let said = ctx.loc.tf("tool.mcp.images_off", &[("n", "1")]);
assert!(out.result.ends_with(&said), "{}", out.result);
}
#[tokio::test]
async fn invoke_is_cancellable_via_ctx_cancel() {
let (client_io, _server_io_keepalive) = tokio::io::duplex(64 * 1024);
let (client_r, client_w) = tokio::io::split(client_io);
let conn = Arc::new(McpConnection::over(client_r, client_w));
let info = McpToolInfo {
name: "slow".into(),
description: String::new(),
input_schema: json!({ "type": "object" }),
};
let tool = McpTool::new("test", &info, conn, Duration::from_secs(60), 100);
let (_d, _s, ctx) = testkit::ctx_with_storage(Uuid::new_v4());
let tok = ctx.cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
tok.cancel();
});
let err = tool.invoke(&ctx, json!({})).await.unwrap_err().to_string();
assert!(err.contains("cancelled"), "{err}");
}
}