#![cfg(feature = "http")]
mod common;
use clap::Parser;
use clap_mcp::{ClapMcp, McpListen, ServeMcpBuilder};
use common::{shutdown, tool_text};
use rmcp::model::{CallToolRequestParams, GetPromptRequestParams, ReadResourceRequestParams};
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe)]
#[clap_mcp_output_from = "run_http_test"]
#[command(name = "http-test-cli")]
enum HttpTestCli {
Echo {
#[arg(long)]
message: String,
},
}
fn run_http_test(cmd: HttpTestCli) -> String {
match cmd {
HttpTestCli::Echo { message } => format!("echo: {message}"),
}
}
#[derive(Clone, Default)]
struct NoOpHandler;
impl ClientHandler for NoOpHandler {}
async fn wait_for_http(addr: SocketAddr) {
for _ in 0..100 {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("HTTP server not ready at {addr}");
}
async fn connect_http_client(
addr: SocketAddr,
) -> rmcp::service::RunningService<RoleClient, NoOpHandler> {
wait_for_http(addr).await;
let uri = format!("http://{addr}/mcp");
NoOpHandler
.serve(StreamableHttpClientTransport::from_uri(uri))
.await
.expect("connect")
}
async fn exercise_inprocess_http(listen: SocketAddr, connect: SocketAddr) {
let server = tokio::spawn(async move {
let _ = ServeMcpBuilder::for_cli::<HttpTestCli>(McpListen::Http(listen))
.serve()
.await;
});
wait_for_http(connect).await;
let uri = format!("http://{connect}/mcp");
let transport = StreamableHttpClientTransport::from_uri(uri);
let client = NoOpHandler.serve(transport).await.expect("connect");
let tools = client.list_tools(None).await.expect("list tools").tools;
assert!(tools.iter().any(|t| t.name == "echo"));
let result = client
.call_tool(
CallToolRequestParams::new("echo").with_arguments(serde_json::Map::from_iter([(
"message".to_string(),
serde_json::json!("inprocess"),
)])),
)
.await
.expect("call echo");
assert!(tool_text(&result).contains("inprocess"));
shutdown(client).await;
server.abort();
let _ = server.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_inprocess_resources_prompts_and_placeholder_tool() {
use clap_mcp::{
ClapMcpConfig, ClapMcpSchemaMetadata, PROMPT_LOGGING_GUIDE, schema_from_command,
};
let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let (tx, rx) = clap_mcp::logging::log_channel(4);
drop(tx);
let schema = schema_from_command(
&clap::Command::new("placeholder-cli")
.subcommand(clap::Command::new("echo").arg(clap::Arg::new("message").long("message"))),
);
let server = tokio::spawn(async move {
let _ = ServeMcpBuilder::new()
.listen(McpListen::Http(addr))
.schema_json(serde_json::to_string(&schema).expect("schema json"))
.config(ClapMcpConfig::default())
.metadata(ClapMcpSchemaMetadata::default())
.serve_options(clap_mcp::ClapMcpServeOptions {
log_rx: Some(rx),
..Default::default()
})
.in_process_handler(None)
.executable_path(None)
.serve()
.await;
});
wait_for_http(addr).await;
let client = connect_http_client(addr).await;
let resources = client
.list_resources(None)
.await
.expect("resources")
.resources;
assert!(resources.iter().any(|r| r.uri == "clap://schema"));
let schema_resource = client
.read_resource(ReadResourceRequestParams::new("clap://schema"))
.await
.expect("read schema");
assert!(common::read_text(&schema_resource).contains("placeholder-cli"));
let prompts = client.list_prompts(None).await.expect("prompts").prompts;
assert!(prompts.iter().any(|p| p.name == PROMPT_LOGGING_GUIDE));
let guide = client
.get_prompt(GetPromptRequestParams::new(PROMPT_LOGGING_GUIDE))
.await
.expect("logging guide");
assert!(common::prompt_has_text(
&guide.messages,
"notifications/message",
));
let placeholder =
client
.call_tool(CallToolRequestParams::new("echo").with_arguments(
serde_json::Map::from_iter([("message".to_string(), serde_json::json!("hi"))]),
))
.await
.expect("placeholder tool");
assert!(tool_text(&placeholder).contains("Would invoke clap command"));
shutdown(client).await;
server.abort();
}
#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe = false, parallel_safe = false)]
#[clap_mcp_output_from = "run_subprocess_http_test"]
#[command(name = "subprocess-http-test-cli")]
enum SubprocessHttpTestCli {
Emit {
#[arg(long, default_value = "ok")]
message: String,
},
}
fn run_subprocess_http_test(cmd: SubprocessHttpTestCli) -> String {
match cmd {
SubprocessHttpTestCli::Emit { message } => {
eprintln!("stderr:{message}");
format!("stdout:{message}")
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_inprocess_subprocess_tool_executes_in_same_coverage_process() {
let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let server = tokio::spawn(async move {
let _ = ServeMcpBuilder::for_cli::<SubprocessHttpTestCli>(McpListen::Http(addr))
.serve()
.await;
});
wait_for_http(addr).await;
let client = connect_http_client(addr).await;
let result =
client
.call_tool(CallToolRequestParams::new("emit").with_arguments(
serde_json::Map::from_iter([("message".to_string(), serde_json::json!("cov"))]),
))
.await
.expect("subprocess tool call should return");
let text = tool_text(&result);
assert!(
text.contains("stdout:cov")
|| text.contains("stderr:cov")
|| text.contains("Would invoke")
|| result.is_error == Some(true),
"unexpected subprocess tool result: {text}"
);
shutdown(client).await;
server.abort();
}
#[derive(Debug, Parser, ClapMcp)]
#[clap_mcp(reinvocation_safe, parallel_safe = false, catch_in_process_panics)]
#[clap_mcp_output_from = "run_panic_http_test"]
#[command(name = "panic-http-test-cli")]
enum PanicHttpTestCli {
PanicDemo,
}
fn run_panic_http_test(cmd: PanicHttpTestCli) -> String {
match cmd {
PanicHttpTestCli::PanicDemo => panic!("coverage panic"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_inprocess_in_process_panic_is_caught() {
let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let server = tokio::spawn(async move {
let _ = ServeMcpBuilder::for_cli::<PanicHttpTestCli>(McpListen::Http(addr))
.serve()
.await;
});
let client = connect_http_client(addr).await;
let result = client
.call_tool(CallToolRequestParams::new("panic-demo"))
.await
.expect("panic tool");
assert_eq!(result.is_error, Some(true));
assert!(tool_text(&result).contains("panicked"));
shutdown(client).await;
server.abort();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_inprocess_serve_list_and_call_tool() {
let listener = tokio::net::TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
exercise_inprocess_http(addr, addr).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_inprocess_unspecified_bind_accepts_localhost() {
let listener =
tokio::net::TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let connect = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), addr.port());
exercise_inprocess_http(addr, connect).await;
}