#![cfg(feature = "mcp")]
#![allow(dead_code)]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::missing_panics_doc,
clippy::missing_errors_doc
)]
use std::time::Duration;
use loopctl::mcp::{CommandSpec, McpClient, McpError, McpToolProvider};
use loopctl::stream::handler::StreamRetryConfig;
fn stdio_server_bin() -> String {
let exe = std::env::current_exe().expect("test binary path");
let profile_dir = exe
.parent()
.and_then(|deps| deps.parent())
.expect("profile directory above the test binary's deps directory");
let name = if cfg!(windows) {
"mcp-stdio-server.exe"
} else {
"mcp-stdio-server"
};
profile_dir
.join("examples")
.join(name)
.to_string_lossy()
.to_string()
}
fn stdio_server_spec() -> CommandSpec {
CommandSpec {
program: stdio_server_bin(),
args: vec![],
env: vec![],
cwd: None,
}
}
async fn discover(client: McpClient) -> Vec<String> {
let provider = McpToolProvider::connect(client, None)
.await
.expect("connect + list_tools");
provider
.tools()
.iter()
.map(loopctl::tool::Tool::name)
.map(str::to_owned)
.collect()
}
fn fast_retry() -> StreamRetryConfig {
StreamRetryConfig {
max_retries: 2,
base_delay_ms: 1,
max_delay_ms: 5,
jitter_factor: 0.0,
}
}
#[tokio::test]
async fn stdio_discovers_tools_from_child() {
let client = McpClient::stdio(stdio_server_spec())
.await
.expect("stdio connect");
let names = discover(client).await;
assert_eq!(names, vec!["greet"], "the example server exposes one tool");
}
#[tokio::test]
async fn stdio_spawn_failure_is_handshake_error() {
let spec = CommandSpec {
program: "/nonexistent/mcp-server-binary-xyz".into(),
args: vec![],
env: vec![],
cwd: None,
};
let err = McpClient::stdio(spec).await.expect_err("spawn must fail");
assert!(matches!(err, McpError::Handshake(_)), "got {err:?}");
}
#[tokio::test]
async fn stdio_command_spec_env_is_applied() {
let spec = CommandSpec {
program: stdio_server_bin(),
args: vec![],
env: vec![("LOOPCTL_TEST_MARKER".into(), "present".into())],
cwd: None,
};
let client = McpClient::stdio(spec).await.expect("connect with env");
let names = discover(client).await;
assert_eq!(names, vec!["greet"]);
}
#[tokio::test]
async fn stdio_drop_does_not_hang() {
let client = McpClient::stdio(stdio_server_spec())
.await
.expect("connect");
tokio::time::timeout(Duration::from_secs(2), async move { drop(client) })
.await
.expect("drop completes within 2s");
}
#[tokio::test]
async fn reconnect_in_process_client_is_error() {
use rmcp::handler::server::ServerHandler;
use rmcp::{ServiceExt, tool, tool_handler, tool_router};
#[derive(Clone)]
struct S {
router: rmcp::handler::server::router::tool::ToolRouter<Self>,
}
#[tool_router]
impl S {
fn new() -> Self {
Self {
router: Self::tool_router(),
}
}
#[tool(description = "noop")]
async fn noop(&self) -> String {
"ok".into()
}
}
#[allow(clippy::unused_async_trait_impl)] #[tool_handler]
impl ServerHandler for S {}
let (server_end, client_end) = tokio::io::duplex(4096);
tokio::spawn(async move {
if let Ok(r) = S::new().serve(server_end).await {
let _ = r.waiting().await.ok();
}
});
let client = ().serve(client_end).await.map(McpClient::from_service).expect("connect");
let err = client
.reconnect(&fast_retry())
.await
.expect_err("in_process cannot reconnect");
assert!(matches!(err, McpError::Handshake(_)), "got {err:?}");
}
#[tokio::test]
async fn reconnect_stdio_re_establishes_after_drop() {
let spec = stdio_server_spec();
let live = McpClient::stdio(spec).await.expect("first connect");
let dead = live.clone();
drop(live);
tokio::time::sleep(Duration::from_millis(100)).await;
let reconnected = dead
.reconnect(&fast_retry())
.await
.expect("reconnect re-spawns the child");
let names = discover(reconnected).await;
assert_eq!(
names,
vec!["greet"],
"reconnected client rediscovers the tool"
);
}
fn e2e_enabled() -> bool {
std::env::var("LOOPCTL_MCP_E2E").is_ok_and(|v| v == "1")
}
#[tokio::test]
#[ignore = "requires LOOPCTL_MCP_E2E=1 and an official SDK streamable-http server on 127.0.0.1:3001"]
async fn http_sse_round_trip_against_local_sdk_server() {
if !e2e_enabled() {
eprintln!("skipped: set LOOPCTL_MCP_E2E=1 to run the live HTTP smoke");
return;
}
let client = McpClient::http_sse("http://127.0.0.1:3001/mcp")
.await
.expect("http connect");
let names = discover(client).await;
assert!(!names.is_empty(), "server advertised tools");
}
#[tokio::test]
#[ignore = "requires LOOPCTL_MCP_E2E=1 and an official SDK streamable-http server on 127.0.0.1:3001"]
async fn http_sse_with_client_round_trips() {
if !e2e_enabled() {
eprintln!("skipped: set LOOPCTL_MCP_E2E=1 to run the live HTTP smoke");
return;
}
let req_client = reqwest::Client::builder().build().expect("reqwest client");
let client = McpClient::http_sse_with_client("http://127.0.0.1:3001/mcp", req_client)
.await
.expect("http connect");
let names = discover(client).await;
assert!(!names.is_empty(), "server advertised tools");
}
fn severed_endpoint() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let addr = listener.local_addr().expect("assigned port");
std::thread::spawn(move || {
if let Ok((conn, _)) = listener.accept() {
drop(conn);
}
});
format!("http://{addr}/mcp")
}
#[tokio::test]
async fn http_sse_connect_refused_is_handshake_error() {
let err = McpClient::http_sse(severed_endpoint())
.await
.expect_err("severed connection must fail the handshake");
assert!(matches!(err, McpError::Handshake(_)), "got {err:?}");
}
#[tokio::test]
async fn http_sse_with_client_connect_refused_is_handshake_error() {
let req_client = reqwest::Client::builder().build().expect("reqwest client");
let err = McpClient::http_sse_with_client(severed_endpoint(), req_client)
.await
.expect_err("severed connection must fail the handshake");
assert!(matches!(err, McpError::Handshake(_)), "got {err:?}");
}