use std::collections::HashMap;
#[cfg(windows)]
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, oneshot};
use tokio_util::sync::CancellationToken;
pub const PROTOCOL_VERSION: &str = "2025-11-25";
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
#[derive(Debug, Clone)]
pub struct McpToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone)]
pub struct McpCallResult {
pub text: String,
pub images: Vec<McpImage>,
pub is_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpImage {
pub mime: String,
pub data: String,
}
use crate::shared::config::MAX_TOOL_RESULT_IMAGES;
type SharedWriter = Arc<Mutex<Box<dyn AsyncWrite + Send + Unpin>>>;
type PendingMap = Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value, String>>>>>;
pub struct McpConnection {
writer: SharedWriter,
pending: PendingMap,
next_id: AtomicI64,
reader_task: tokio::task::JoinHandle<()>,
}
impl Drop for McpConnection {
fn drop(&mut self) {
self.reader_task.abort();
}
}
impl McpConnection {
pub fn over(
reader: impl AsyncRead + Send + Unpin + 'static,
writer: impl AsyncWrite + Send + Unpin + 'static,
) -> Self {
let writer: SharedWriter = Arc::new(Mutex::new(Box::new(writer)));
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let reader_task = tokio::spawn(read_loop(reader, writer.clone(), pending.clone()));
Self {
writer,
pending,
next_id: AtomicI64::new(1),
reader_task,
}
}
pub async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
self.request_cancellable(method, params, timeout, None)
.await
}
pub async fn request_cancellable(
&self,
method: &str,
params: Value,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
self.send_line(&msg).await?;
let cancelled = async {
match cancel {
Some(tok) => tok.cancelled().await,
None => std::future::pending::<()>().await,
}
};
let outcome = tokio::select! {
res = tokio::time::timeout(timeout, rx) => res,
_ = cancelled => {
self.abandon_request(id, "cancelled").await;
bail!("MCP {method}: call cancelled")
}
};
match outcome {
Ok(Ok(Ok(result))) => Ok(result),
Ok(Ok(Err(rpc_err))) => bail!("MCP {method}: {rpc_err}"),
Ok(Err(_)) => bail!("MCP {method}: connection closed by the server"),
Err(_) => {
self.abandon_request(id, "timeout").await;
bail!("MCP {method}: timed out after {}s", timeout.as_secs())
}
}
}
async fn abandon_request(&self, id: i64, reason: &str) {
self.pending.lock().await.remove(&id);
let cancel = json!({
"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": { "requestId": id, "reason": reason }
});
let _ = self.send_line(&cancel).await;
}
pub async fn list_tools(&self) -> Result<Vec<McpToolInfo>> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = match &cursor {
Some(c) => json!({ "cursor": c }),
None => json!({}),
};
let page = self
.request("tools/list", params, HANDSHAKE_TIMEOUT)
.await?;
for t in page
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
tools.push(McpToolInfo {
name: t
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
description: t
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
input_schema: t
.get("inputSchema")
.cloned()
.unwrap_or_else(|| json!({ "type": "object" })),
});
}
cursor = page
.get("nextCursor")
.and_then(Value::as_str)
.map(str::to_string);
if cursor.is_none() {
return Ok(tools);
}
}
}
pub async fn call_tool(
&self,
name: &str,
arguments: Value,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> Result<McpCallResult> {
let result = self
.request_cancellable(
"tools/call",
json!({ "name": name, "arguments": arguments }),
timeout,
cancel,
)
.await?;
let mut text = String::new();
let mut images: Vec<McpImage> = Vec::new();
let mut dropped = 0usize;
let push_line = |text: &mut String, line: &str| {
if !text.is_empty() {
text.push('\n');
}
text.push_str(line);
};
for block in result
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match block.get("type").and_then(Value::as_str) {
Some("text") => push_line(
&mut text,
block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
),
Some("image") => match (
block.get("data").and_then(Value::as_str),
block.get("mimeType").and_then(Value::as_str),
) {
(Some(data), Some(mime)) if !data.is_empty() => {
if images.len() < MAX_TOOL_RESULT_IMAGES {
images.push(McpImage {
mime: mime.to_string(),
data: data.to_string(),
});
} else {
dropped += 1;
}
}
_ => push_line(&mut text, "[image content omitted]"),
},
Some(other) => push_line(&mut text, &format!("[{other} content omitted]")),
None => {}
}
}
if dropped > 0 {
push_line(
&mut text,
&format!(
"[{dropped} more image(s) were returned but not included: at most \
{MAX_TOOL_RESULT_IMAGES} images per tool result. You have not seen \
them — do not describe what they show; say that you cannot see them.]"
),
);
}
Ok(McpCallResult {
text,
images,
is_error: result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
pub async fn notify(&self, method: &str, params: Value) -> Result<()> {
self.send_line(&json!({ "jsonrpc": "2.0", "method": method, "params": params }))
.await
}
async fn send_line(&self, msg: &Value) -> Result<()> {
let mut line = serde_json::to_string(msg)?;
line.push('\n');
let mut w = self.writer.lock().await;
w.write_all(line.as_bytes()).await?;
w.flush().await?;
Ok(())
}
}
async fn read_loop(
reader: impl AsyncRead + Send + Unpin + 'static,
writer: SharedWriter,
pending: PendingMap,
) {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => {
tracing::warn!(line = %clip_line(&line), "MCP: non-JSON line on stdout, skipped");
continue;
}
};
let id = msg.get("id");
let has_method = msg.get("method").is_some();
match (id, has_method) {
(Some(id_v), false) => deliver_reply(id_v, &msg, &pending).await,
(Some(id_v), true) => answer_server_request(id_v, &msg, &writer).await,
(None, true) => {}
_ => {}
}
}
pending.lock().await.clear();
}
async fn deliver_reply(id_v: &Value, msg: &Value, pending: &PendingMap) {
let Some(id) = id_v.as_i64() else { return };
if let Some(tx) = pending.lock().await.remove(&id) {
let outcome = match msg.get("error") {
Some(e) => Err(e
.get("message")
.and_then(Value::as_str)
.unwrap_or("error with no description")
.to_string()),
None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)),
};
let _ = tx.send(outcome);
}
}
async fn answer_server_request(id_v: &Value, msg: &Value, writer: &SharedWriter) {
let method = msg["method"].as_str().unwrap_or_default();
let reply = if method == "ping" {
json!({ "jsonrpc": "2.0", "id": id_v, "result": {} })
} else {
json!({ "jsonrpc": "2.0", "id": id_v,
"error": { "code": -32601, "message": "method not found" } })
};
let mut line = reply.to_string();
line.push('\n');
let mut w = writer.lock().await;
let _ = w.write_all(line.as_bytes()).await;
let _ = w.flush().await;
}
fn clip_line(s: &str) -> &str {
&s[..s.len().min(200)]
}
pub fn valid_server_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 32
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn valid_env_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(windows)]
pub fn resolve_command(command: &str) -> Option<std::path::PathBuf> {
let exts: Vec<String> = std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
.split(';')
.filter(|e| !e.is_empty())
.map(|e| e.to_ascii_lowercase())
.collect();
let dirs: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
resolve_in(command, &dirs, &exts)
}
#[cfg(windows)]
fn resolve_in(
command: &str,
dirs: &[std::path::PathBuf],
exts: &[String],
) -> Option<std::path::PathBuf> {
let cmd = command.trim();
if cmd.is_empty() {
return None;
}
let has_ext = Path::new(cmd).extension().is_some();
let candidates = |dir: &Path| -> Option<std::path::PathBuf> {
let base = dir.join(cmd);
has_ext
.then(|| base.clone())
.into_iter()
.chain(exts.iter().map(|ext| {
let mut s = base.clone().into_os_string();
s.push(ext);
std::path::PathBuf::from(s)
}))
.find(|p| p.is_file())
};
if cmd.contains(['/', '\\']) || Path::new(cmd).is_absolute() {
return candidates(Path::new(""));
}
dirs.iter().find_map(|dir| candidates(dir))
}
#[cfg(not(windows))]
pub fn resolve_command(_command: &str) -> Option<std::path::PathBuf> {
None
}
pub struct McpClient {
conn: Arc<McpConnection>,
kill: CancellationToken,
exited: CancellationToken,
pub server_info: String,
pub protocol_version: String,
}
impl Drop for McpClient {
fn drop(&mut self) {
self.kill.cancel();
}
}
impl McpClient {
pub async fn spawn(program: &str, args: &[String], envs: &[(String, String)]) -> Result<Self> {
let resolved = resolve_command(program);
let mut cmd = match &resolved {
Some(path) => Command::new(path),
None => Command::new(program),
};
cmd.args(args)
.envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
{
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd.spawn().with_context(|| match &resolved {
Some(p) if p.as_os_str() != program => {
format!("launching MCP server: {program} ({})", p.display())
}
_ => format!("launching MCP server: {program}"),
})?;
let job = crate::shared::proc::TreeGuard::assign(&child);
let stdout = child.stdout.take().expect("stdout piped");
let stdin = child.stdin.take().expect("stdin piped");
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(line = %clip_line(&line), "MCP stderr");
}
});
}
let kill = CancellationToken::new();
let exited = CancellationToken::new();
spawn_monitor(child, job, kill.clone(), exited.clone());
let conn = Arc::new(McpConnection::over(stdout, stdin));
let init = conn
.request(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": "mindfork",
"version": env!("CARGO_PKG_VERSION"),
}
}),
HANDSHAKE_TIMEOUT,
)
.await?;
let protocol_version = init
.get("protocolVersion")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string();
let server_info = init
.get("serverInfo")
.map(|si| {
format!(
"{} {}",
si.get("name").and_then(Value::as_str).unwrap_or("?"),
si.get("version").and_then(Value::as_str).unwrap_or("?")
)
})
.unwrap_or_else(|| "?".into());
conn.notify("notifications/initialized", json!({})).await?;
Ok(Self {
conn,
kill,
exited,
server_info,
protocol_version,
})
}
pub fn conn(&self) -> Arc<McpConnection> {
self.conn.clone()
}
pub fn exited(&self) -> CancellationToken {
self.exited.clone()
}
pub async fn list_tools(&self) -> Result<Vec<McpToolInfo>> {
self.conn.list_tools().await
}
pub async fn shutdown(self) {
let exited = self.exited.clone();
drop(self); exited.cancelled().await;
}
}
fn spawn_monitor(
mut child: Child,
job: crate::shared::proc::TreeGuard,
kill: CancellationToken,
exited: CancellationToken,
) {
tokio::spawn(async move {
let _job = job;
tokio::select! {
status = child.wait() => {
match status {
Ok(s) => tracing::warn!(status = ?s, "MCP server exited on its own"),
Err(e) => tracing::warn!(error = %e, "error waiting for the MCP server"),
}
}
_ = kill.cancelled() => {
if tokio::time::timeout(SHUTDOWN_GRACE, child.wait()).await.is_err() {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
}
exited.cancel();
});
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncBufReadExt, BufReader};
#[test]
fn env_variable_name_rule() {
assert!(valid_env_name("GITHUB_TOKEN"));
assert!(valid_env_name("a1"));
assert!(!valid_env_name(""));
assert!(!valid_env_name("1A"));
assert!(!valid_env_name("has-dash"));
assert!(!valid_env_name("has space"));
}
fn fake_server<F>(script: F) -> (McpConnection, tokio::task::JoinHandle<Vec<Value>>)
where
F: Fn(&Value) -> Option<Value> + Send + 'static,
{
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);
let handle = tokio::spawn(async move {
let mut received = Vec::new();
let mut lines = BufReader::new(server_r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
received.push(msg.clone());
if let Some(reply) = script(&msg) {
let mut out = reply.to_string();
out.push('\n');
if server_w.write_all(out.as_bytes()).await.is_err() {
break;
}
let _ = server_w.flush().await;
}
}
received
});
(McpConnection::over(client_r, client_w), handle)
}
fn scripted(msg: &Value) -> Option<Value> {
let id = msg.get("id")?.clone();
match msg["method"].as_str()? {
"initialize" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "fake", "version": "0.1" }
}})),
"tools/list" => {
let params = msg.get("params").cloned().unwrap_or(json!({}));
if params.get("cursor").is_none() {
Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"tools": [{ "name": "echo", "description": "Echo text",
"inputSchema": { "type": "object" } }],
"nextCursor": "p2"
}}))
} else {
Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"tools": [{ "name": "add", "description": "Add numbers",
"inputSchema": { "type": "object" } }]
}}))
}
}
"tools/call" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [ { "type": "text", "text": "hello" },
{ "type": "image", "data": "…", "mimeType": "image/png" } ],
"isError": false
}})),
_ => None,
}
}
async fn handshake(conn: &McpConnection) -> Value {
let init = conn
.request(
"initialize",
json!({ "protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": { "name": "t", "version": "0" } }),
Duration::from_secs(2),
)
.await
.unwrap();
conn.notify("notifications/initialized", json!({}))
.await
.unwrap();
init
}
#[tokio::test]
async fn handshake_lists_and_calls_over_duplex() {
let (conn, _fake) = fake_server(scripted);
let init = handshake(&conn).await;
assert_eq!(init["protocolVersion"], "2025-06-18");
let p1 = conn
.request("tools/list", json!({}), Duration::from_secs(2))
.await
.unwrap();
assert_eq!(p1["nextCursor"], "p2");
let p2 = conn
.request(
"tools/list",
json!({ "cursor": "p2" }),
Duration::from_secs(2),
)
.await
.unwrap();
assert!(p2.get("nextCursor").is_none());
let call = conn
.request(
"tools/call",
json!({ "name": "echo", "arguments": { "text": "hi" } }),
Duration::from_secs(2),
)
.await
.unwrap();
assert_eq!(call["content"][0]["text"], "hello");
}
#[tokio::test]
async fn garbage_line_then_valid_response() {
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();
let Ok(Some(line)) = lines.next_line().await else {
return;
};
let msg: Value = serde_json::from_str(&line).unwrap();
let id = msg["id"].clone();
server_w
.write_all(b"Starting fake MCP server v1.0!\n")
.await
.unwrap();
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": { "ok": true } });
server_w
.write_all(format!("{reply}\n").as_bytes())
.await
.unwrap();
});
let conn = McpConnection::over(client_r, client_w);
let r = conn
.request("x", json!({}), Duration::from_secs(2))
.await
.unwrap();
assert_eq!(r["ok"], true);
}
#[tokio::test]
async fn answers_ping_and_rejects_unknown_server_requests() {
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);
let _conn = McpConnection::over(client_r, client_w);
let ping = json!({ "jsonrpc": "2.0", "id": 100, "method": "ping" });
let sampling = json!({ "jsonrpc": "2.0", "id": 101, "method": "sampling/createMessage", "params": {} });
server_w
.write_all(format!("{ping}\n{sampling}\n").as_bytes())
.await
.unwrap();
let mut lines = BufReader::new(server_r).lines();
let pong: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
assert_eq!(pong["id"], 100);
assert!(pong.get("result").is_some(), "ping → empty result");
let mnf: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
assert_eq!(mnf["id"], 101);
assert_eq!(mnf["error"]["code"], -32601);
}
#[tokio::test]
async fn timeout_sends_cancelled_notification() {
let (conn, fake) = fake_server(|msg| {
let id = msg.get("id")?.clone();
(msg["method"].as_str()? != "tools/call")
.then(|| json!({ "jsonrpc": "2.0", "id": id, "result": {} }))
});
let err = conn
.request(
"tools/call",
json!({ "name": "slow" }),
Duration::from_millis(100),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("timed out"), "{err}");
drop(conn); let received = fake.await.unwrap();
assert!(
received
.iter()
.any(|m| m["method"] == "notifications/cancelled"
&& m["params"]["reason"] == "timeout"),
"no notifications/cancelled: {received:?}"
);
}
#[tokio::test]
async fn cancel_sends_cancelled_notification() {
let (conn, fake) = fake_server(|msg| {
let id = msg.get("id")?.clone();
(msg["method"].as_str()? != "tools/call")
.then(|| json!({ "jsonrpc": "2.0", "id": id, "result": {} }))
});
let cancel = CancellationToken::new();
let tok = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
tok.cancel();
});
let err = conn
.call_tool("slow", json!({}), Duration::from_secs(30), Some(&cancel))
.await
.unwrap_err()
.to_string();
assert!(err.contains("cancelled"), "{err}");
drop(conn);
let received = fake.await.unwrap();
assert!(
received
.iter()
.any(|m| m["method"] == "notifications/cancelled"
&& m["params"]["reason"] == "cancelled"),
"no notifications/cancelled: {received:?}"
);
}
#[tokio::test]
async fn call_tool_joins_text_and_carries_images() {
let (conn, _fake) = fake_server(scripted);
let res = conn
.call_tool(
"echo",
json!({ "text": "hi" }),
Duration::from_secs(2),
None,
)
.await
.unwrap();
assert_eq!(res.text, "hello", "the image leaves no placeholder behind");
assert_eq!(
res.images,
vec![McpImage {
mime: "image/png".into(),
data: "…".into(),
}]
);
assert!(!res.is_error);
}
#[tokio::test]
async fn audio_and_broken_image_blocks_keep_their_placeholder() {
let (conn, _fake) = fake_server(|msg| {
let id = msg.get("id").cloned().unwrap_or(json!(1));
match msg["method"].as_str().unwrap_or_default() {
"initialize" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "fake", "version": "0.1" }
}})),
"tools/call" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [
{ "type": "audio", "data": "…", "mimeType": "audio/wav" },
{ "type": "image", "mimeType": "image/png" },
],
"isError": false
}})),
_ => None,
}
});
let res = conn
.call_tool("t", json!({}), Duration::from_secs(2), None)
.await
.unwrap();
assert!(res.images.is_empty());
assert!(res.text.contains("[audio content omitted]"), "{}", res.text);
assert!(res.text.contains("[image content omitted]"), "{}", res.text);
}
#[tokio::test]
async fn the_image_cap_drops_the_extras_and_says_so() {
let (conn, _fake) = fake_server(|msg| {
let id = msg.get("id").cloned().unwrap_or(json!(1));
match msg["method"].as_str().unwrap_or_default() {
"initialize" => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "fake", "version": "0.1" }
}})),
"tools/call" => {
let blocks: Vec<_> = (0..MAX_TOOL_RESULT_IMAGES + 3)
.map(|i| {
json!({ "type": "image", "data": format!("d{i}"),
"mimeType": "image/png" })
})
.collect();
Some(json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": blocks, "isError": false
}}))
}
_ => None,
}
});
let res = conn
.call_tool("t", json!({}), Duration::from_secs(2), None)
.await
.unwrap();
assert_eq!(res.images.len(), MAX_TOOL_RESULT_IMAGES);
assert_eq!(res.images[0].data, "d0");
assert_eq!(res.images[MAX_TOOL_RESULT_IMAGES - 1].data, "d3");
assert!(
res.text.contains('3'),
"the count of dropped ones: {}",
res.text
);
assert!(res.text.contains("not included"), "{}", res.text);
}
#[test]
fn batch_commands_are_forbidden() {
assert!(resolve_command("").is_none());
assert!(resolve_command("definitely-not-a-real-program-xyz").is_none());
}
#[tokio::test]
async fn spawn_reports_which_file_it_failed_to_launch() {
let err = match McpClient::spawn("definitely-not-a-real-program-xyz", &[], &[]).await {
Ok(_) => panic!("a nonexistent program should not spawn"),
Err(e) => format!("{e:#}"),
};
assert!(err.contains("definitely-not-a-real-program-xyz"), "{err}");
}
#[cfg(windows)]
#[test]
fn resolve_completes_pathext_on_windows() {
let resolved = resolve_command("cmd").expect("cmd.exe is on PATH");
assert_eq!(
resolved.extension().map(|e| e.to_ascii_lowercase()),
Some("exe".into()),
"resolved to {resolved:?}"
);
assert!(resolved.is_file());
let exact = resolve_command("cmd.exe").expect("cmd.exe is on PATH");
assert_eq!(exact, resolved);
}
#[cfg(windows)]
#[test]
fn bare_name_never_resolves_to_the_extensionless_twin() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("npx"), "#!/bin/sh\n").unwrap();
std::fs::write(dir.path().join("npx.cmd"), "@echo off\n").unwrap();
let dirs = vec![dir.path().to_path_buf()];
let exts: Vec<String> = [".com", ".exe", ".bat", ".cmd"].map(String::from).to_vec();
let got = resolve_in("npx", &dirs, &exts).expect("npx.cmd should be found");
assert_eq!(got, dir.path().join("npx.cmd"), "picked the shell script");
assert_eq!(
resolve_in("npx.cmd", &dirs, &exts).unwrap(),
dir.path().join("npx.cmd")
);
let full = dir.path().join("npx").to_string_lossy().replace('\\', "/");
assert_eq!(
resolve_in(&full, &[], &exts).unwrap(),
dir.path().join("npx.cmd")
);
std::fs::write(dir.path().join("my.tool.exe"), "").unwrap();
assert_eq!(
resolve_in("my.tool", &dirs, &exts).unwrap(),
dir.path().join("my.tool.exe")
);
assert!(resolve_in("nothing-here", &dirs, &exts).is_none());
}
#[tokio::test]
async fn rpc_error_surfaces_as_error() {
let (conn, _fake) = fake_server(|msg| {
let id = msg.get("id")?.clone();
Some(json!({ "jsonrpc": "2.0", "id": id,
"error": { "code": -32602, "message": "Unknown tool" } }))
});
let err = conn
.request(
"tools/call",
json!({ "name": "nope" }),
Duration::from_secs(2),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("Unknown tool"), "{err}");
}
}
#[cfg(test)]
mod ignored_smoke {
use super::*;
use crate::entities::sampling::SamplingConfig;
use crate::shared::api::contract::{
ApiMessage, ChatChunk, ChatRequest, EngineBackend, FinishReason, ToolCallAccumulator,
ToolSchema,
};
use futures_util::StreamExt;
async fn spawn_filesystem_server(allowed_dir: &str) -> Result<McpClient> {
let (program, args): (&str, Vec<String>) = if cfg!(windows) {
(
"cmd",
[
"/c",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
allowed_dir,
]
.map(String::from)
.to_vec(),
)
} else {
(
"npx",
["-y", "@modelcontextprotocol/server-filesystem", allowed_dir]
.map(String::from)
.to_vec(),
)
};
McpClient::spawn(program, &args, &[]).await
}
#[tokio::test]
#[ignore = "requires MINDFORK_ENGINE_URL + npx (real filesystem MCP server)"]
async fn gemma_reads_file_via_mcp_filesystem_server() {
let Some(engine) =
crate::shared::api::live_client("MINDFORK_ENGINE_URL", "MINDFORK_ENGINE_KEY")
else {
eprintln!("skip: MINDFORK_ENGINE_URL not set");
return;
};
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("secret_number.txt"),
"Секретное число: 7319",
)
.unwrap();
let allowed = dir.path().to_string_lossy().replace('\\', "/");
let client = match spawn_filesystem_server(&allowed).await {
Ok(c) => c,
Err(e) => {
eprintln!("skip: failed to launch the npx MCP server: {e:#}");
return;
}
};
eprintln!(
"MCP server: {} (protocol {})",
client.server_info, client.protocol_version
);
let tools = client.list_tools().await.unwrap();
assert!(!tools.is_empty(), "the filesystem server has tools");
let schemas: Vec<ToolSchema> = tools
.iter()
.map(|t| ToolSchema {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.input_schema.clone(),
})
.collect();
let schema_bytes: usize = schemas
.iter()
.map(|s| s.name.len() + s.description.len() + s.parameters.to_string().len())
.sum();
eprintln!(
"tools: {}, schemas ≈ {} KiB (16k-context budget)",
schemas.len(),
schema_bytes / 1024
);
let mut messages = vec![ApiMessage::user(format!(
"Прочитай файл {allowed}/secret_number.txt с помощью инструмента и скажи, \
какое секретное число в нём записано."
))];
let mut rounds = 0;
let final_text = loop {
rounds += 1;
assert!(rounds <= 8, "the model got stuck looping on tool calls");
let req = ChatRequest {
continue_final: false,
system: Some(
"Ты — ассистент с инструментами файловой системы. Пользуйся ими.".into(),
),
messages: messages.clone(),
sampling: SamplingConfig {
max_tokens: Some(1024),
..Default::default()
},
tools: schemas.clone(),
};
let mut stream = engine.chat_stream(req, Default::default()).await.unwrap();
let mut text = String::new();
let mut acc = ToolCallAccumulator::default();
let mut finish = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::ToolCall(d) => acc.push(d),
ChatChunk::Finished(r) => {
finish = Some(r);
break;
}
_ => {}
}
}
let calls = acc.finish();
match finish {
Some(FinishReason::ToolCalls) if !calls.is_empty() => {
messages.push(ApiMessage::assistant_tool_calls(text, calls.clone()));
for call in calls {
let args: Value =
serde_json::from_str(&call.arguments).unwrap_or(json!({}));
eprintln!("→ model calls {}({})", call.name, call.arguments);
let result = client
.conn()
.call_tool(&call.name, args, Duration::from_secs(30), None)
.await
.unwrap();
eprintln!(
"← result ({}): {}",
if result.is_error { "error" } else { "ok" },
&result.text[..result.text.len().min(120)]
);
messages.push(ApiMessage::tool(call.id, result.text));
}
}
_ => break text,
}
};
eprintln!("rounds: {rounds}; final answer: {final_text}");
assert!(
final_text.contains("7319"),
"the model didn't use the MCP tool's result: {final_text:?}"
);
assert!(
rounds >= 2,
"the tool wasn't called (an answer with no rounds)"
);
client.shutdown().await;
}
}