use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crossbeam_channel::{bounded, Receiver, RecvTimeoutError};
use serde_json::{json, Map, Value};
use crate::config::{Config, McpServerConfig};
const CALL_TIMEOUT: Duration = Duration::from_secs(25);
const INIT_TIMEOUT: Duration = Duration::from_secs(45);
const DEFAULT_MAX_TOOLS: usize = 8;
const MAX_LINE_BYTES: usize = 1 << 20;
const MAX_REQUEST_BYTES: usize = 16 * 1024;
const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
pub const SEP: &str = "__";
pub struct McpServer {
pub name: String,
conn: Mutex<Conn>,
#[cfg(unix)]
pid: u32,
reaped: AtomicBool,
pub tools: Vec<McpTool>,
}
#[derive(Debug, Clone)]
pub struct McpTool {
pub qualified: String,
pub remote: String,
pub declaration: Value,
}
struct Conn {
child: Child,
stdin: ChildStdin,
broken: bool,
rx: Receiver<Value>,
stash: VecDeque<Value>,
next_id: u64,
}
#[derive(Default)]
pub struct McpPool {
pub servers: Vec<McpServer>,
}
impl McpPool {
pub fn launch(config: &Config) -> (Self, Vec<String>) {
let mut servers = Vec::new();
let mut problems = Vec::new();
for spec in &config.voice_live.mcp_servers {
if spec.name.trim().is_empty() || spec.command.trim().is_empty() {
problems.push("an mcp_servers entry is missing name or command".to_string());
continue;
}
match McpServer::start(spec) {
Ok(server) => servers.push(server),
Err(e) => problems.push(format!("{}: {e}", spec.name)),
}
}
(Self { servers }, problems)
}
pub fn declarations(&self) -> Vec<Value> {
let mut seen: Vec<String> = Vec::new();
let mut out = Vec::new();
for server in &self.servers {
for tool in &server.tools {
if seen.iter().any(|n| n == &tool.qualified) {
continue;
}
seen.push(tool.qualified.clone());
out.push(tool.declaration.clone());
}
}
out
}
pub fn call(&self, qualified: &str, args: &Value) -> Option<Result<Value, String>> {
let server = self
.servers
.iter()
.find(|s| s.tools.iter().any(|t| t.qualified == qualified))?;
let remote = server
.tools
.iter()
.find(|t| t.qualified == qualified)?
.remote
.clone();
Some(server.call(&remote, args))
}
pub fn shutdown(&self) {
for server in &self.servers {
server.shutdown();
}
}
}
impl McpServer {
fn start(spec: &McpServerConfig) -> Result<Self, String> {
let program = crate::summarize::resolve_agent_path(&spec.command);
let mut child = crate::engine_process::command(&program)
.args(&spec.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("could not start {}: {e}", spec.command))?;
let stdin = child.stdin.take().ok_or("no stdin on the server")?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
let fd = stdin.as_raw_fd();
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
}
let stdout = child.stdout.take().ok_or("no stdout on the server")?;
let (tx, rx) = bounded::<Value>(256);
std::thread::Builder::new()
.name(format!("mcp-{}", spec.name))
.spawn(move || {
let mut reader = BufReader::new(stdout);
let mut line = Vec::new();
loop {
line.clear();
let mut limited = (&mut reader).take(MAX_LINE_BYTES as u64);
match limited.read_until(b'\n', &mut line) {
Ok(0) => return,
Ok(n) => {
if n >= MAX_LINE_BYTES && !line.ends_with(b"\n") {
return;
}
}
Err(_) => return,
}
let text = String::from_utf8_lossy(&line);
let text = text.trim();
if text.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<Value>(text) {
if tx.send(value).is_err() {
return;
}
}
}
})
.map_err(|e| format!("could not read from the server: {e}"))?;
#[cfg(unix)]
let pid = child.id();
let server = Self {
name: spec.name.clone(),
#[cfg(unix)]
pid,
reaped: AtomicBool::new(false),
conn: Mutex::new(Conn {
child,
stdin,
broken: false,
rx,
stash: VecDeque::new(),
next_id: 1,
}),
tools: Vec::new(),
};
if let Err(e) = server.handshake() {
server.shutdown();
return Err(e);
}
let tools = match server.load_tools(spec) {
Ok(tools) => tools,
Err(e) => {
server.shutdown();
return Err(e);
}
};
Ok(Self { tools, ..server })
}
fn handshake(&self) -> Result<(), String> {
self.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "minutes-voice-live", "version": env!("CARGO_PKG_VERSION") },
}),
INIT_TIMEOUT,
)?;
self.notify("notifications/initialized", json!({}))
}
fn load_tools(&self, spec: &McpServerConfig) -> Result<Vec<McpTool>, String> {
let listed = self.request("tools/list", json!({}), INIT_TIMEOUT)?;
let raw = listed
.get("tools")
.and_then(Value::as_array)
.ok_or("tools/list returned no tools array")?;
Ok(select_tools(&spec.name, raw, &spec.tools, spec.max_tools))
}
fn call(&self, remote: &str, args: &Value) -> Result<Value, String> {
let result = self.request(
"tools/call",
json!({ "name": remote, "arguments": args }),
CALL_TIMEOUT,
)?;
Ok(flatten_tool_result(&result))
}
fn notify(&self, method: &str, params: Value) -> Result<(), String> {
let mut conn = self.conn.lock().map_err(|_| "server lock poisoned")?;
if conn.broken {
return Err("that server is no longer usable".into());
}
let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string();
let sent = write_line(&mut conn.stdin, &msg);
if sent.is_err() {
conn.broken = true;
}
sent
}
fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let mut conn = self.conn.lock().map_err(|_| "server lock poisoned")?;
if conn.broken {
return Err("that server is no longer usable".into());
}
let id = conn.next_id;
conn.next_id += 1;
let msg =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
if msg.len() > MAX_REQUEST_BYTES {
return Err(format!(
"that request is too large to send to {method} safely"
));
}
if let Err(e) = write_line(&mut conn.stdin, &msg) {
conn.broken = true;
return Err(e);
}
if let Some(pos) = conn.stash.iter().position(|v| response_id(v) == Some(id)) {
let hit = conn.stash.remove(pos).expect("position just found");
return unwrap_response(&hit);
}
let deadline = Instant::now() + timeout;
loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(format!("{method} timed out after {}s", timeout.as_secs()));
}
match conn.rx.recv_timeout(left) {
Ok(value) => match response_id(&value) {
Some(got) if got == id => return unwrap_response(&value),
Some(_) => {
if conn.stash.len() >= 32 {
conn.stash.pop_front();
}
conn.stash.push_back(value);
}
None => {}
},
Err(RecvTimeoutError::Timeout) => {
return Err(format!("{method} timed out after {}s", timeout.as_secs()))
}
Err(RecvTimeoutError::Disconnected) => return Err("the server exited".into()),
}
}
}
fn shutdown(&self) {
if self.reaped.load(Ordering::SeqCst) {
return;
}
if let Ok(mut conn) = self.conn.try_lock() {
let _ = conn.child.kill();
let _ = conn.child.wait();
self.reaped.store(true, Ordering::SeqCst);
return;
}
#[cfg(unix)]
if self.pid > 0 {
unsafe {
libc::kill(self.pid as libc::pid_t, libc::SIGKILL);
}
}
}
}
impl Drop for McpPool {
fn drop(&mut self) {
self.shutdown();
}
}
fn write_line(stdin: &mut ChildStdin, line: &str) -> Result<(), String> {
let bytes = line.as_bytes();
let deadline = Instant::now() + WRITE_TIMEOUT;
let mut sent = 0;
while sent < bytes.len() {
if Instant::now() > deadline {
return Err("the server stopped reading its input".into());
}
match stdin.write(&bytes[sent..]) {
Ok(0) => return Err("the server closed its input".into()),
Ok(n) => sent += n,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(format!("writing to the server: {e}")),
}
}
loop {
if Instant::now() > deadline {
return Err("the server stopped reading its input".into());
}
match stdin.write(b"\n") {
Ok(0) => return Err("the server closed its input".into()),
Ok(_) => break,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(format!("writing to the server: {e}")),
}
}
Ok(())
}
fn response_id(value: &Value) -> Option<u64> {
if value.get("method").is_some() {
return None;
}
if value.get("result").is_none() && value.get("error").is_none() {
return None;
}
value.get("id").and_then(Value::as_u64)
}
fn unwrap_response(value: &Value) -> Result<Value, String> {
if let Some(error) = value.get("error") {
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error");
return Err(message.to_string());
}
Ok(value.get("result").cloned().unwrap_or(Value::Null))
}
pub fn flatten_tool_result(result: &Value) -> Value {
if let Some(structured) = result.get("structuredContent") {
return structured.clone();
}
let Some(blocks) = result.get("content").and_then(Value::as_array) else {
return result.clone();
};
let text: Vec<String> = blocks
.iter()
.filter_map(|b| match b.get("type").and_then(Value::as_str) {
Some("text") => b.get("text").and_then(Value::as_str).map(str::to_string),
Some(other) => Some(format!("[{other} content, not readable aloud]")),
None => None,
})
.collect();
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
json!({ "ok": !is_error, "text": text.join("\n") })
}
pub fn select_tools(
server: &str,
raw: &[Value],
allowlist: &[String],
max_tools: usize,
) -> Vec<McpTool> {
let cap = if max_tools == 0 {
DEFAULT_MAX_TOOLS
} else {
max_tools
};
let mut out = Vec::new();
for tool in raw {
if out.len() >= cap {
break;
}
let Some(remote) = tool.get("name").and_then(Value::as_str) else {
continue;
};
if !allowlist.is_empty() && !allowlist.iter().any(|a| a == remote) {
continue;
}
let qualified = qualify(server, remote);
if qualified.is_empty() {
continue;
}
if out.iter().any(|t: &McpTool| t.qualified == qualified) {
continue;
}
let description = tool
.get("description")
.and_then(Value::as_str)
.unwrap_or("No description given by the server.");
let schema = tool
.get("inputSchema")
.cloned()
.unwrap_or_else(|| json!({"type": "object", "properties": {}}));
out.push(McpTool {
declaration: json!({
"name": qualified,
"description": format!("[{server}] {description}"),
"parameters": sanitize_schema(&schema),
"behavior": "NON_BLOCKING",
}),
qualified,
remote: remote.to_string(),
});
}
out
}
pub fn qualify(server: &str, tool: &str) -> String {
let clean = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
};
let (server, tool) = (clean(server), clean(tool));
if server.is_empty() || tool.is_empty() {
return String::new();
}
let mut name = format!("{server}{SEP}{tool}");
name.truncate(64);
name
}
pub fn sanitize_schema(schema: &Value) -> Value {
sanitize_inner(schema, false)
}
fn sanitize_inner(schema: &Value, keys_are_names: bool) -> Value {
const DROP: &[&str] = &[
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"additionalProperties",
"patternProperties",
"allOf",
"oneOf",
"not",
"if",
"then",
"else",
"const",
"examples",
"default",
"minLength",
"maxLength",
"pattern",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"uniqueItems",
"minItems",
"maxItems",
];
match schema {
Value::Object(map) => {
let mut clean = Map::new();
for (key, value) in map {
if !keys_are_names && DROP.contains(&key.as_str()) {
continue;
}
let child_keys_are_names = !keys_are_names && key == "properties";
clean.insert(key.clone(), sanitize_inner(value, child_keys_are_names));
}
if !keys_are_names && !clean.contains_key("type") && clean.contains_key("properties") {
clean.insert("type".into(), json!("object"));
}
Value::Object(clean)
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|item| sanitize_inner(item, false))
.collect(),
),
other => other.clone(),
}
}
#[cfg(test)]
impl McpServer {
fn for_test(name: &str, tools: Vec<McpTool>) -> Self {
let (_tx, rx) = bounded::<Value>(1);
Self {
name: name.to_string(),
#[cfg(unix)]
pid: 0,
reaped: AtomicBool::new(true),
conn: Mutex::new(Conn {
child: crate::engine_process::command("true")
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.expect("true should spawn"),
stdin: {
let mut helper = crate::engine_process::command("true")
.stdin(Stdio::piped())
.spawn()
.expect("true should spawn");
let pipe = helper.stdin.take().expect("piped stdin");
let _ = helper.wait();
pipe
},
broken: false,
rx,
stash: VecDeque::new(),
next_id: 1,
}),
tools,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_qualified_name_is_legal_and_reversible_enough() {
assert_eq!(
qualify("hubspot", "search_contacts"),
"hubspot__search_contacts"
);
assert_eq!(qualify("my server", "get/thing"), "my_server__get_thing");
assert!(qualify("", "x").is_empty());
assert!(qualify("x", "").is_empty());
let long = qualify(&"s".repeat(40), &"t".repeat(40));
assert!(long.len() <= 64, "function names are capped at 64");
}
#[test]
fn unsupported_schema_keywords_are_dropped_at_every_depth() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"query": { "type": "string", "minLength": 1, "description": "keep me" },
"nested": { "type": "object", "properties": { "x": { "type": "number", "maximum": 5 } } }
},
"required": ["query"]
});
let clean = sanitize_schema(&schema);
let text = clean.to_string();
for banned in ["$schema", "additionalProperties", "minLength", "maximum"] {
assert!(!text.contains(banned), "{banned} survived");
}
assert_eq!(clean["type"], "object");
assert_eq!(clean["properties"]["query"]["description"], "keep me");
assert_eq!(clean["required"][0], "query");
assert_eq!(
clean["properties"]["nested"]["properties"]["x"]["type"],
"number"
);
}
#[test]
fn an_argument_named_like_a_keyword_survives() {
let clean = sanitize_schema(&json!({
"type": "object",
"additionalProperties": false,
"properties": {
"pattern": {"type": "string", "description": "a regex"},
"default": {"type": "string"}
},
"required": ["pattern"]
}));
assert_eq!(clean["properties"]["pattern"]["description"], "a regex");
assert!(clean["properties"].get("default").is_some());
assert!(clean.get("additionalProperties").is_none());
let nested = sanitize_schema(&json!({
"properties": {"q": {"type": "string", "minLength": 2}}
}));
assert!(nested["properties"]["q"].get("minLength").is_none());
}
#[test]
fn a_name_cannot_be_claimed_by_two_servers() {
let raw = vec![json!({"name": "c", "inputSchema": {"type": "object", "properties": {}}})];
let first = select_tools("a", &[json!({"name": "b__c", "inputSchema": {}})], &[], 0);
let second = select_tools("a__b", &raw, &[], 0);
assert_eq!(first[0].qualified, second[0].qualified);
let pool = McpPool {
servers: vec![
McpServer::for_test("a", first),
McpServer::for_test("a__b", second),
],
};
assert_eq!(pool.declarations().len(), 1);
}
#[test]
fn two_remote_names_cannot_become_one_function() {
let raw = vec![
json!({"name": "get/thing", "inputSchema": {"type": "object", "properties": {}}}),
json!({"name": "get_thing", "inputSchema": {"type": "object", "properties": {}}}),
];
let picked = select_tools("s", &raw, &[], 0);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].remote, "get/thing");
}
#[test]
fn a_missing_type_is_restored_when_there_are_properties() {
let clean = sanitize_schema(&json!({"properties": {"a": {"type": "string"}}}));
assert_eq!(clean["type"], "object");
}
fn tool(name: &str) -> Value {
json!({"name": name, "description": "d", "inputSchema": {"type": "object", "properties": {}}})
}
#[test]
fn an_allowlist_selects_and_a_cap_bounds_the_tool_surface() {
let raw: Vec<Value> = (0..20).map(|i| tool(&format!("t{i}"))).collect();
assert_eq!(select_tools("s", &raw, &[], 0).len(), DEFAULT_MAX_TOOLS);
assert_eq!(select_tools("s", &raw, &[], 3).len(), 3);
let picked = select_tools("s", &raw, &["t2".into(), "t5".into()], 0);
assert_eq!(picked.len(), 2);
assert_eq!(picked[0].qualified, "s__t2");
assert_eq!(picked[0].remote, "t2");
assert!(picked[0].declaration["description"]
.as_str()
.unwrap()
.starts_with("[s] "));
}
#[test]
fn a_tool_result_becomes_speakable_text() {
let flat = flatten_tool_result(&json!({
"content": [{"type": "text", "text": "line one"}, {"type": "text", "text": "line two"}]
}));
assert_eq!(flat["ok"], true);
assert_eq!(flat["text"], "line one\nline two");
let failed = flatten_tool_result(&json!({
"content": [{"type": "text", "text": "nope"}], "isError": true
}));
assert_eq!(failed["ok"], false);
let structured =
flatten_tool_result(&json!({"structuredContent": {"count": 3}, "content": []}));
assert_eq!(structured["count"], 3);
let image = flatten_tool_result(&json!({"content": [{"type": "image", "data": "x"}]}));
assert!(image["text"].as_str().unwrap().contains("image content"));
}
#[test]
fn an_error_response_surfaces_its_message() {
let err = unwrap_response(&json!({"id": 1, "error": {"message": "boom"}}));
assert_eq!(err.unwrap_err(), "boom");
let ok = unwrap_response(&json!({"id": 1, "result": {"a": 1}})).unwrap();
assert_eq!(ok["a"], 1);
}
}