use std::time::Duration;
use serde_json::{Value, json};
use crate::core::mcp_config::{
builtin_server_entry, managed_mcp_server_names, strip_arg_separator,
};
#[cfg(test)]
mod tests;
pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_LINE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestTransport {
Stdio,
Http,
Sse,
}
impl TestTransport {
pub fn as_str(self) -> &'static str {
match self {
TestTransport::Stdio => "stdio",
TestTransport::Http => "http",
TestTransport::Sse => "sse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpTestOutcome {
Ok {
tools: Option<usize>,
},
Malformed(String),
Spawn(String),
Timeout,
Protocol(String),
Unreachable(String),
}
#[derive(Debug, Clone)]
pub struct TestTarget {
pub name: String,
pub entry: Value,
pub transport: TestTransport,
}
#[derive(Debug, Clone)]
pub struct McpTestResult {
pub name: String,
pub transport: TestTransport,
pub outcome: McpTestOutcome,
}
impl McpTestResult {
pub fn passed(&self) -> bool {
matches!(self.outcome, McpTestOutcome::Ok { .. })
}
pub fn render(&self) -> (&'static str, String) {
match &self.outcome {
McpTestOutcome::Ok { tools: Some(n) } => ("PASS", format!("{n} tools")),
McpTestOutcome::Ok { tools: None } => ("PASS", "reachable".to_string()),
McpTestOutcome::Malformed(m) => ("FAIL", format!("malformed: {m}")),
McpTestOutcome::Spawn(m) => ("FAIL", format!("spawn error: {m}")),
McpTestOutcome::Timeout => ("FAIL", "timeout".to_string()),
McpTestOutcome::Protocol(m) => ("FAIL", format!("protocol error: {m}")),
McpTestOutcome::Unreachable(m) => ("FAIL", format!("unreachable: {m}")),
}
}
pub fn to_json(&self) -> Value {
let (result, error, tools): (&str, Option<String>, Option<usize>) = match &self.outcome {
McpTestOutcome::Ok { tools } => ("ok", None, *tools),
McpTestOutcome::Malformed(m) => ("malformed", Some(m.clone()), None),
McpTestOutcome::Spawn(m) => ("spawn_error", Some(m.clone()), None),
McpTestOutcome::Timeout => ("timeout", None, None),
McpTestOutcome::Protocol(m) => ("protocol_error", Some(m.clone()), None),
McpTestOutcome::Unreachable(m) => ("unreachable", Some(m.clone()), None),
};
json!({
"name": self.name,
"transport": self.transport.as_str(),
"result": result,
"tools": tools,
"error": error,
"passed": self.passed(),
})
}
}
pub fn any_failed(results: &[McpTestResult]) -> bool {
results.iter().any(|r| !r.passed())
}
pub fn classify_transport(entry: &Value) -> TestTransport {
match entry.get("type").and_then(Value::as_str) {
Some("http") => TestTransport::Http,
Some("sse") => TestTransport::Sse,
Some("stdio") => TestTransport::Stdio,
_ if entry.get("url").is_some() => TestTransport::Http,
_ => TestTransport::Stdio,
}
}
pub fn select_targets(
servers: &serde_json::Map<String, Value>,
name: Option<&str>,
) -> anyhow::Result<Vec<TestTarget>> {
match name {
Some(n) => {
let entry = servers
.get(n)
.cloned()
.or_else(|| builtin_server_entry(n))
.ok_or_else(|| {
anyhow::anyhow!(
"MCP server '{n}' not found among configured servers or framework built-ins"
)
})?;
Ok(vec![make_target(n.to_string(), entry)])
}
None => {
let config = json!({ "mcpServers": Value::Object(servers.clone()) });
let names = managed_mcp_server_names(&config);
let targets = names
.into_iter()
.map(|n| {
let entry = servers
.get(&n)
.cloned()
.or_else(|| builtin_server_entry(&n))
.unwrap_or(Value::Null);
make_target(n, entry)
})
.collect();
Ok(targets)
}
}
}
fn make_target(name: String, entry: Value) -> TestTarget {
let transport = classify_transport(&entry);
TestTarget {
name,
entry,
transport,
}
}
pub fn initialize_request(id: i64) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "tm-mcp-test", "version": env!("CARGO_PKG_VERSION") }
}
})
}
pub fn initialized_notification() -> Value {
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
}
pub fn tools_list_request(id: i64) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": "tools/list", "params": {} })
}
pub fn validate_initialize(resp: &Value) -> Result<(), String> {
if let Some(err) = resp.get("error") {
return Err(rpc_error_message(err));
}
resp.get("result")
.and_then(|r| r.get("protocolVersion"))
.ok_or_else(|| "initialize response missing result.protocolVersion".to_string())?;
Ok(())
}
pub fn parse_tool_count(resp: &Value) -> Result<usize, String> {
if let Some(err) = resp.get("error") {
return Err(rpc_error_message(err));
}
let tools = resp
.get("result")
.and_then(|r| r.get("tools"))
.and_then(Value::as_array)
.ok_or_else(|| "tools/list response missing result.tools array".to_string())?;
Ok(tools.len())
}
fn rpc_error_message(err: &Value) -> String {
let code = err.get("code").and_then(Value::as_i64).unwrap_or(0);
let msg = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error");
format!("server error {code}: {msg}")
}
fn entry_args(entry: &Value) -> Vec<String> {
let raw: Vec<String> = entry
.get("args")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
strip_arg_separator(&raw).to_vec()
}
fn entry_env(entry: &Value) -> Vec<(String, String)> {
entry
.get("env")
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|vs| (k.clone(), vs.to_string())))
.collect()
})
.unwrap_or_default()
}
pub async fn run_all(targets: Vec<TestTarget>, timeout: Duration) -> Vec<McpTestResult> {
let mut results = Vec::with_capacity(targets.len());
for target in targets {
let outcome = probe_target(&target, timeout).await;
results.push(McpTestResult {
name: target.name,
transport: target.transport,
outcome,
});
}
results
}
pub async fn probe_target(target: &TestTarget, timeout: Duration) -> McpTestOutcome {
match target.transport {
TestTransport::Stdio => probe_stdio(&target.entry, timeout).await,
TestTransport::Http | TestTransport::Sse => probe_http(&target.entry, timeout).await,
}
}
async fn probe_stdio(entry: &Value, timeout: Duration) -> McpTestOutcome {
let Some(command) = entry.get("command").and_then(Value::as_str) else {
return McpTestOutcome::Malformed("stdio entry missing 'command'".to_string());
};
let args = entry_args(entry);
let env = entry_env(entry);
match tokio::time::timeout(timeout, stdio_handshake(command, &args, &env)).await {
Ok(Ok(count)) => McpTestOutcome::Ok { tools: Some(count) },
Ok(Err(outcome)) => outcome,
Err(_) => McpTestOutcome::Timeout,
}
}
async fn stdio_handshake(
command: &str,
args: &[String],
env: &[(String, String)],
) -> Result<usize, McpTestOutcome> {
use std::process::Stdio;
use tokio::io::BufReader;
use tokio::process::Command;
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd
.spawn()
.map_err(|e| McpTestOutcome::Spawn(format!("{command}: {e}")))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| McpTestOutcome::Protocol("child stdin unavailable".to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| McpTestOutcome::Protocol("child stdout unavailable".to_string()))?;
let mut reader = BufReader::new(stdout);
write_message(&mut stdin, &initialize_request(1)).await?;
let init_resp = read_response(&mut reader, 1).await?;
validate_initialize(&init_resp).map_err(McpTestOutcome::Protocol)?;
write_message(&mut stdin, &initialized_notification()).await?;
write_message(&mut stdin, &tools_list_request(2)).await?;
let list_resp = read_response(&mut reader, 2).await?;
let count = parse_tool_count(&list_resp).map_err(McpTestOutcome::Protocol)?;
Ok(count)
}
async fn write_message<W>(writer: &mut W, msg: &Value) -> Result<(), McpTestOutcome>
where
W: tokio::io::AsyncWriteExt + Unpin,
{
let mut line = serde_json::to_string(msg)
.map_err(|e| McpTestOutcome::Protocol(format!("serialise request: {e}")))?;
line.push('\n');
writer
.write_all(line.as_bytes())
.await
.map_err(|e| McpTestOutcome::Protocol(format!("write to server: {e}")))?;
writer
.flush()
.await
.map_err(|e| McpTestOutcome::Protocol(format!("flush to server: {e}")))?;
Ok(())
}
async fn read_response<R>(reader: &mut R, want_id: i64) -> Result<Value, McpTestOutcome>
where
R: tokio::io::AsyncBufRead + Unpin,
{
loop {
match read_bounded_line(reader, MAX_LINE_BYTES).await? {
Some(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: Value = serde_json::from_str(trimmed).map_err(|e| {
McpTestOutcome::Protocol(format!("non-JSON line from server: {e}"))
})?;
match value.get("id").and_then(Value::as_i64) {
Some(id) if id == want_id => return Ok(value),
_ => continue, }
}
None => {
return Err(McpTestOutcome::Protocol(
"server closed stdout before responding".to_string(),
));
}
}
}
}
async fn read_bounded_line<R>(
reader: &mut R,
max_bytes: usize,
) -> Result<Option<String>, McpTestOutcome>
where
R: tokio::io::AsyncBufRead + Unpin,
{
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
let mut buf: Vec<u8> = Vec::new();
let n = {
let mut limited = reader.take(max_bytes as u64);
limited
.read_until(b'\n', &mut buf)
.await
.map_err(|e| McpTestOutcome::Protocol(format!("read from server: {e}")))?
};
if n == 0 {
return Ok(None); }
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
}
if n >= max_bytes {
return Err(McpTestOutcome::Protocol("line too long".to_string()));
}
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
}
async fn probe_http(entry: &Value, timeout: Duration) -> McpTestOutcome {
let Some(url) = entry.get("url").and_then(Value::as_str) else {
return McpTestOutcome::Malformed("http/sse entry missing 'url'".to_string());
};
let client = match reqwest::Client::builder().timeout(timeout).build() {
Ok(c) => c,
Err(e) => return McpTestOutcome::Unreachable(format!("HTTP client build failed: {e}")),
};
let mut req = client.get(url);
if let Some(headers) = entry.get("headers").and_then(Value::as_object) {
for (k, v) in headers {
if let Some(vs) = v.as_str() {
req = req.header(k, vs);
}
}
}
match req.send().await {
Ok(_resp) => McpTestOutcome::Ok { tools: None },
Err(e) => McpTestOutcome::Unreachable(format!("{e}")),
}
}