use serde_json::{json, Value};
use std::io::Write;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::time::timeout;
pub struct McpTestServer {
process: Child,
next_id: i64,
}
impl McpTestServer {
pub async fn new() -> anyhow::Result<Self> {
let process = Command::new(env!("CARGO_BIN_EXE_skill"))
.arg("serve")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null()) .kill_on_drop(true)
.spawn()?;
Ok(Self {
process,
next_id: 1,
})
}
async fn send_request(&mut self, request: Value) -> anyhow::Result<Value> {
let stdin = self
.process
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?;
let stdout = self
.process
.stdout
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to get stdout"))?;
let request_str = serde_json::to_string(&request)? + "\n";
stdin.write_all(request_str.as_bytes()).await?;
stdin.flush().await?;
let mut reader = BufReader::new(stdout);
let mut line = String::new();
timeout(Duration::from_secs(10), reader.read_line(&mut line)).await??;
let response: Value = serde_json::from_str(&line)?;
Ok(response)
}
pub async fn initialize(&mut self) -> anyhow::Result<Value> {
let request = json!({
"jsonrpc": "2.0",
"method": "initialize",
"id": self.next_id,
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
});
self.next_id += 1;
let response = self.send_request(request).await?;
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let notification_str = serde_json::to_string(¬ification)? + "\n";
let stdin = self
.process
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?;
stdin.write_all(notification_str.as_bytes()).await?;
stdin.flush().await?;
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(response)
}
pub async fn list_tools(&mut self) -> anyhow::Result<Value> {
let request = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"id": self.next_id,
"params": {}
});
self.next_id += 1;
self.send_request(request).await
}
pub async fn execute_tool(
&mut self,
skill: &str,
tool: &str,
args: Value,
context_opts: Option<Value>,
) -> anyhow::Result<Value> {
let mut arguments = json!({
"skill": skill,
"tool": tool,
"args": args
});
if let Some(opts) = context_opts {
if let (Some(args_obj), Some(opts_obj)) = (arguments.as_object_mut(), opts.as_object())
{
for (key, value) in opts_obj {
args_obj.insert(key.clone(), value.clone());
}
}
}
let request = json!({
"jsonrpc": "2.0",
"method": "tools/call",
"id": self.next_id,
"params": {
"name": "execute",
"arguments": arguments
}
});
self.next_id += 1;
self.send_request(request).await
}
}
#[tokio::test]
#[ignore] async fn test_mcp_tool_execution() {
let mut server = McpTestServer::new().await.unwrap();
let init_response = server.initialize().await.unwrap();
assert_eq!(init_response["jsonrpc"], "2.0");
assert!(init_response["result"]["protocolVersion"]
.as_str()
.unwrap()
.starts_with("2024"));
let response = server
.execute_tool(
"kubernetes",
"get",
json!({"resource": "namespaces"}),
None,
)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("result").is_some());
assert!(response.get("error").is_none());
}
#[tokio::test]
#[ignore] async fn test_mcp_context_engineering_grep() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool(
"kubernetes",
"get",
json!({"resource": "namespaces"}),
Some(json!({"grep": "default"})),
)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("result").is_some());
let result_str = response["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
result_str.contains("default"),
"Grep filter should include 'default'"
);
}
#[tokio::test]
#[ignore] async fn test_mcp_context_engineering_head() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool(
"kubernetes",
"get",
json!({"resource": "namespaces"}),
Some(json!({"head": 3})),
)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("result").is_some());
let result_str = response["result"]["content"][0]["text"]
.as_str()
.unwrap();
let line_count = result_str.lines().count();
assert!(
line_count <= 3,
"Head should limit output to 3 lines, got {}",
line_count
);
}
#[tokio::test]
#[ignore] async fn test_mcp_context_engineering_jq() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool(
"kubernetes",
"get",
json!({"resource": "namespaces", "output": "json"}),
Some(json!({"jq": ".items[].metadata.name"})),
)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("result").is_some());
let result_str = response["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
result_str.contains("default") || result_str.contains("kube-"),
"JQ should extract namespace names"
);
}
#[tokio::test]
#[ignore] async fn test_mcp_context_engineering_max_output() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool(
"kubernetes",
"get",
json!({"resource": "pods", "all-namespaces": "true"}),
Some(json!({"max_output": 500})),
)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("result").is_some());
let result_str = response["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
result_str.len() <= 600, "Max output should truncate to ~500 chars, got {}",
result_str.len()
);
}
#[tokio::test]
#[ignore] async fn test_mcp_error_invalid_skill() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool("nonexistent_skill_xyz", "get", json!({}), None)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("error").is_some());
assert!(response["error"]["message"]
.as_str()
.unwrap()
.contains("skill"));
}
#[tokio::test]
#[ignore] async fn test_mcp_error_invalid_tool() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool("kubernetes", "nonexistent_tool_xyz", json!({}), None)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(response.get("error").is_some());
}
#[tokio::test]
#[ignore] async fn test_mcp_error_missing_params() {
let mut server = McpTestServer::new().await.unwrap();
server.initialize().await.unwrap();
let response = server
.execute_tool("kubernetes", "get", json!({}), None)
.await
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert!(
response.get("error").is_some() || response["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("error"),
"Should return error for missing required parameter"
);
}