use std::process::Stdio;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
async fn spawn_mcp() -> (
tokio::process::Child,
tokio::process::ChildStdin,
tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
) {
let mut child = Command::new(env!("CARGO_BIN_EXE_netscli"))
.arg("serve")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("spawn netscli serve");
let stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let lines = BufReader::new(stdout).lines();
(child, stdin, lines)
}
async fn read_json_line(
lines: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
) -> Value {
let line = timeout(Duration::from_secs(15), lines.next_line())
.await
.expect("timeout waiting for MCP response")
.expect("read line")
.expect("server closed stdout");
serde_json::from_str(&line).expect("response must be valid json")
}
async fn initialize(
stdin: &mut tokio::process::ChildStdin,
lines: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
) {
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}
"#,
)
.await
.expect("write initialize");
stdin.flush().await.expect("flush initialize");
let init_resp = read_json_line(lines).await;
assert_eq!(init_resp["id"], 1);
assert!(init_resp.get("error").is_none() || init_resp["error"].is_null());
assert_eq!(init_resp["result"]["protocolVersion"], "2024-11-05");
}
#[tokio::test]
async fn mcp_initialize_then_tools_list() {
let (mut child, mut stdin, mut lines) = spawn_mcp().await;
initialize(&mut stdin, &mut lines).await;
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
"#,
)
.await
.expect("write tools/list");
stdin.flush().await.expect("flush tools/list");
let tools_resp = read_json_line(&mut lines).await;
assert_eq!(tools_resp["id"], 2);
let tools = tools_resp["result"]["tools"]
.as_array()
.expect("tools array");
assert!(!tools.is_empty());
drop(stdin);
let _ = timeout(Duration::from_secs(10), child.wait())
.await
.expect("timeout waiting for server exit")
.expect("wait");
}
#[tokio::test]
async fn mcp_requires_initialize_for_methods() {
let (mut child, mut stdin, mut lines) = spawn_mcp().await;
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":1,"method":"scan_ports","params":{"host":"127.0.0.1","ports":[80]}}
"#,
)
.await
.expect("write scan_ports");
stdin.flush().await.expect("flush scan_ports");
let resp = read_json_line(&mut lines).await;
assert_eq!(resp["id"], 1);
assert_eq!(resp["error"]["code"], -32002);
assert!(resp["error"]["message"]
.as_str()
.unwrap_or("")
.contains("Not initialized"));
drop(stdin);
let _ = timeout(Duration::from_secs(10), child.wait())
.await
.expect("timeout waiting for server exit")
.expect("wait");
}
#[tokio::test]
async fn mcp_tools_call_rejects_large_subnet() {
let (mut child, mut stdin, mut lines) = spawn_mcp().await;
initialize(&mut stdin, &mut lines).await;
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"discover_network","arguments":{"subnet":"10.0.0.0/8"}}}
"#,
)
.await
.expect("write tools/call discover_network");
stdin
.flush()
.await
.expect("flush tools/call discover_network");
let resp = read_json_line(&mut lines).await;
assert_eq!(resp["id"], 2);
assert_eq!(resp["error"]["code"], -32602);
assert!(resp["error"]["message"]
.as_str()
.unwrap_or("")
.contains("subnet too large"));
drop(stdin);
let _ = timeout(Duration::from_secs(10), child.wait())
.await
.expect("timeout waiting for server exit")
.expect("wait");
}
#[tokio::test]
async fn mcp_tools_call_rejects_port_zero() {
let (mut child, mut stdin, mut lines) = spawn_mcp().await;
initialize(&mut stdin, &mut lines).await;
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scan_ports","arguments":{"host":"127.0.0.1","ports":[0]}}}
"#,
)
.await
.expect("write tools/call scan_ports");
stdin.flush().await.expect("flush tools/call scan_ports");
let resp = read_json_line(&mut lines).await;
assert_eq!(resp["id"], 2);
assert_eq!(resp["error"]["code"], -32602);
assert!(resp["error"]["message"]
.as_str()
.unwrap_or("")
.contains("port 0"));
drop(stdin);
let _ = timeout(Duration::from_secs(10), child.wait())
.await
.expect("timeout waiting for server exit")
.expect("wait");
}
#[tokio::test]
async fn mcp_tools_call_scan_ports_returns_richer_port_results() {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let accept_task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept scan connection");
socket
.write_all(b"SSH-2.0-mcp-test\r\n")
.await
.expect("write banner");
});
let (mut child, mut stdin, mut lines) = spawn_mcp().await;
initialize(&mut stdin, &mut lines).await;
let request = format!(
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"scan_ports","arguments":{{"host":"127.0.0.1","ports":[{port}]}}}}}}
"#
);
stdin
.write_all(request.as_bytes())
.await
.expect("write tools/call scan_ports");
stdin.flush().await.expect("flush tools/call scan_ports");
let resp = read_json_line(&mut lines).await;
assert_eq!(resp["id"], 2);
assert!(resp.get("error").is_none() || resp["error"].is_null());
let text = resp["result"]["content"][0]["text"]
.as_str()
.expect("text content");
let payload: Value = serde_json::from_str(text).expect("tool text JSON payload");
let ports = payload.as_array().expect("scan_ports payload array");
assert_eq!(ports.len(), 1);
let item = &ports[0];
assert_eq!(item["port"], port);
assert_eq!(item["open"], true);
assert_eq!(item["status"], "open");
assert!(item["latency_ms"].is_number());
assert_eq!(item["banner"], "SSH-2.0-mcp-test");
accept_task.await.expect("listener task");
drop(stdin);
let _ = timeout(Duration::from_secs(10), child.wait())
.await
.expect("timeout waiting for server exit")
.expect("wait");
}