use std::future::Future;
use std::process::Command as StdCommand;
use std::sync::Arc;
use std::time::Duration;
use rmcp::model::{CallToolRequestParams, ContentBlock, ProtocolVersion};
use rmcp::service::{ClientCacheConfig, ClientInitializeError, RoleClient, RunningService};
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientWorker;
use rmcp::{ClientLifecycleMode, ClientServiceExt};
use crate::tool::{SharedState, Tool, ToolError, ToolSchema};
#[derive(Debug)]
enum ConnSpec {
Args { program: String, args: Vec<String> },
Command(StdCommand),
Consumed,
Url(String),
}
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_LIST_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60);
pub struct McpClient {
server_name: String,
spec: ConnSpec,
prefix: bool,
running: Option<Arc<RunningService<RoleClient, ()>>>,
connect_timeout: Duration,
list_timeout: Duration,
call_timeout: Duration,
}
impl McpClient {
pub fn from_command(
server_name: impl Into<String>,
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
server_name: server_name.into(),
spec: ConnSpec::Args {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
},
prefix: true,
running: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
list_timeout: DEFAULT_LIST_TIMEOUT,
call_timeout: DEFAULT_CALL_TIMEOUT,
}
}
pub fn from_command_configured(server_name: impl Into<String>, command: StdCommand) -> Self {
Self {
server_name: server_name.into(),
spec: ConnSpec::Command(command),
prefix: true,
running: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
list_timeout: DEFAULT_LIST_TIMEOUT,
call_timeout: DEFAULT_CALL_TIMEOUT,
}
}
pub fn from_url(server_name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
server_name: server_name.into(),
spec: ConnSpec::Url(url.into()),
prefix: true,
running: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
list_timeout: DEFAULT_LIST_TIMEOUT,
call_timeout: DEFAULT_CALL_TIMEOUT,
}
}
pub fn with_name_prefix(&mut self, enabled: bool) -> &mut Self {
self.prefix = enabled;
self
}
pub fn with_connect_timeout(&mut self, timeout: Duration) -> &mut Self {
self.connect_timeout = timeout;
self
}
pub fn with_list_timeout(&mut self, timeout: Duration) -> &mut Self {
self.list_timeout = timeout;
self
}
pub fn with_call_timeout(&mut self, timeout: Duration) -> &mut Self {
self.call_timeout = timeout;
self
}
pub fn server_name(&self) -> &str {
&self.server_name
}
pub async fn connect(&mut self) -> Result<(), McpError> {
if self.running.is_some() {
return Ok(());
}
let server_name = self.server_name.clone();
let running = match std::mem::replace(&mut self.spec, ConnSpec::Consumed) {
ConnSpec::Args { program, args } => {
let result = {
let mut command = StdCommand::new(program.clone());
command.args(args.clone());
let transport =
match TokioChildProcess::new(tokio::process::Command::from(command)) {
Ok(transport) => transport,
Err(e) => {
self.spec = ConnSpec::Args { program, args };
return Err(McpError::Connect {
server: server_name.clone(),
message: format!("spawn failed: {e}"),
});
}
};
serve_with_timeout(
&server_name,
self.connect_timeout,
().serve_with_lifecycle(transport, lifecycle_mode()),
)
.await
};
self.spec = ConnSpec::Args { program, args };
result
}
ConnSpec::Command(command) => {
let program = command.get_program().to_string_lossy().to_string();
let transport = TokioChildProcess::new(tokio::process::Command::from(command))
.map_err(|e| McpError::Connect {
server: server_name.clone(),
message: format!("spawn {program}: {e}"),
})?;
serve_with_timeout(
&server_name,
self.connect_timeout,
().serve_with_lifecycle(transport, lifecycle_mode()),
)
.await
}
ConnSpec::Url(url) => {
let result = serve_with_timeout(
&server_name,
self.connect_timeout,
().serve_with_lifecycle(
StreamableHttpClientWorker::<reqwest::Client>::new_simple(url.clone()),
lifecycle_mode(),
),
)
.await;
self.spec = ConnSpec::Url(url);
result
}
ConnSpec::Consumed => {
return Err(McpError::Connect {
server: server_name.clone(),
message: "stdio command already consumed; create a new McpClient to reconnect"
.into(),
});
}
}?;
running
.peer()
.set_response_cache_config(ClientCacheConfig::disabled())
.await;
self.running = Some(Arc::new(running));
Ok(())
}
pub async fn cleanup(&mut self) -> Result<(), McpError> {
self.running = None;
Ok(())
}
pub async fn tools(&mut self) -> Result<Vec<McpTool>, McpError> {
self.connect().await?;
let Some(running) = self.running.clone() else {
return Err(McpError::ListTools {
server: self.server_name.clone(),
message: "no active connection".into(),
});
};
let tools = tokio::time::timeout(self.list_timeout, running.peer().list_all_tools())
.await
.map_err(|_| McpError::ListTools {
server: self.server_name.clone(),
message: "list tools timed out".into(),
})?
.map_err(|e| McpError::ListTools {
server: self.server_name.clone(),
message: e.to_string(),
})?;
if tools.len() > MAX_MCP_TOOLS {
return Err(McpError::ListTools {
server: self.server_name.clone(),
message: format!(
"server exposes too many tools ({} > {MAX_MCP_TOOLS})",
tools.len()
),
});
}
Ok(tools
.into_iter()
.map(|tool| {
McpTool::new(
&self.server_name,
tool,
self.prefix,
self.call_timeout,
Arc::clone(&running),
)
})
.collect())
}
}
impl std::fmt::Debug for McpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpClient")
.field("server_name", &self.server_name)
.field("spec", &self.spec)
.field("prefix", &self.prefix)
.field("connected", &self.running.is_some())
.finish()
}
}
#[derive(Clone)]
pub struct McpTool {
name: String,
raw_name: String,
description: String,
parameters: serde_json::Value,
call_timeout: Duration,
peer: Arc<RunningService<RoleClient, ()>>,
}
impl McpTool {
fn new(
server_name: &str,
tool: rmcp::model::Tool,
prefix: bool,
call_timeout: Duration,
peer: Arc<RunningService<RoleClient, ()>>,
) -> Self {
let mapped = map_tool(server_name, tool, prefix);
Self {
name: mapped.name,
raw_name: mapped.raw_name,
description: mapped.description,
parameters: mapped.parameters,
call_timeout,
peer,
}
}
}
struct MappedTool {
name: String,
raw_name: String,
description: String,
parameters: serde_json::Value,
}
fn map_tool(server_name: &str, tool: rmcp::model::Tool, prefix: bool) -> MappedTool {
let raw_name = tool.name.to_string();
MappedTool {
name: tool_display_name(server_name, &raw_name, prefix),
raw_name,
description: tool.description.unwrap_or_default().to_string(),
parameters: serde_json::Value::Object((*tool.input_schema).clone()),
}
}
impl std::fmt::Debug for McpTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpTool")
.field("name", &self.name)
.field("raw_name", &self.raw_name)
.finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl Tool for McpTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: self.name.clone(),
description: self.description.clone(),
parameters: self.parameters.clone(),
}
}
async fn call(
&self,
arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
let Some(args) = arguments.as_object() else {
return Err(ToolError::InvalidArguments(
"mcp tool arguments must be a JSON object".into(),
));
};
let params = CallToolRequestParams::new(self.raw_name.clone()).with_arguments(args.clone());
let result = tokio::time::timeout(self.call_timeout, self.peer.peer().call_tool(params))
.await
.map_err(|_| ToolError::Execution("mcp tool call timed out".into()))?
.map_err(|e| ToolError::Execution(format!("mcp tool call failed: {e}")))?;
let mut text = content_to_text(&result.content);
if let Some(structured) = &result.structured_content {
let rendered = serde_json::to_string_pretty(structured).map_err(|e| {
ToolError::Execution(format!("structured content serialization failed: {e}"))
})?;
text = format!("{text}\n[structured]\n{rendered}");
}
if result.is_error.unwrap_or(false) {
let message = if text.is_empty() {
"mcp tool reported an error".to_string()
} else {
text
};
Err(ToolError::Execution(message))
} else {
Ok(text)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum McpError {
#[error("mcp connect failed (server {server}): {message}")]
Connect {
server: String,
message: String,
},
#[error("mcp list tools failed (server {server}): {message}")]
ListTools {
server: String,
message: String,
},
}
async fn serve_with_timeout(
server_name: &str,
timeout: Duration,
serve: impl Future<Output = Result<RunningService<RoleClient, ()>, ClientInitializeError>>,
) -> Result<RunningService<RoleClient, ()>, McpError> {
match tokio::time::timeout(timeout, serve).await {
Ok(result) => result.map_err(|e| McpError::Connect {
server: server_name.to_string(),
message: e.to_string(),
}),
Err(_) => Err(McpError::Connect {
server: server_name.to_string(),
message: "connect timed out".into(),
}),
}
}
fn lifecycle_mode() -> ClientLifecycleMode {
ClientLifecycleMode::Auto {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
legacy_version: Some(ProtocolVersion::V_2025_11_25),
}
}
fn tool_display_name(server_name: &str, raw: &str, prefix: bool) -> String {
if prefix {
format!("{server_name}__{raw}")
} else {
raw.to_string()
}
}
const MAX_TOOL_RESULT_BYTES: usize = 1024 * 1024;
const MAX_MCP_TOOLS: usize = 512;
fn content_to_text(blocks: &[ContentBlock]) -> String {
let mut out = String::new();
for (i, block) in blocks.iter().enumerate() {
let part = match block {
ContentBlock::Text(text) => text.text.clone(),
ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
ContentBlock::Audio(audio) => format!("[audio: {}]", audio.mime_type),
ContentBlock::Resource(_) => "[resource]".into(),
ContentBlock::ResourceLink(_) => "[resource link]".into(),
_ => {
tracing::warn!("mcp tool result contains an unknown content block");
"[content]".into()
}
};
if out.len() + part.len() + usize::from(i > 0) > MAX_TOOL_RESULT_BYTES {
out.push_str("[truncated: result exceeds size limit]");
return out;
}
if i > 0 {
out.push('\n');
}
out.push_str(&part);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::ToolRegistry;
use rmcp::model::{
CallToolResponse, CallToolResult, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool as RmcpTool,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::transport::stdio;
use rmcp::{ErrorData, ServerHandler, serve_server};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn http_roundtrip_list_and_call_tools() {
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
loop {
let (mut socket, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => break,
};
tokio::spawn(async move {
let mut buf = [0u8; 64 * 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let body = req.split("\r\n\r\n").nth(1).unwrap_or("");
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
break;
};
let id = value["id"].clone();
let result = match value["method"].as_str() {
Some("server/discover") => json!({
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {} },
"ttlMs": 0,
"cacheScope": "public",
}),
Some("tools/list") => json!({
"tools": [{
"name": "echo",
"description": "echo",
"inputSchema": { "type": "object", "properties": {} },
}],
"nextCursor": null,
"ttlMs": 0,
"cacheScope": "public",
}),
Some("tools/call") => json!({
"resultType": "complete",
"content": [{ "type": "text", "text": "pong" }],
}),
_ => json!({}),
};
let resp_body =
serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result })
.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
resp_body.len(),
resp_body
);
if socket.write_all(resp.as_bytes()).await.is_err() {
break;
}
}
});
}
});
let mut client = McpClient::from_url("http-server", format!("http://{addr}"));
let tools = tokio::time::timeout(Duration::from_secs(10), client.tools())
.await
.expect("tools() timed out")
.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].schema().name, "http-server__echo");
let state = crate::tool::SharedState::new();
let text = tokio::time::timeout(Duration::from_secs(10), tools[0].call(json!({}), &state))
.await
.expect("call timed out")
.unwrap();
assert_eq!(text, "pong");
server.abort();
}
fn spawn_http_mock(
respond: impl Fn(&str, &serde_json::Value) -> Option<serde_json::Value> + Send + Sync + 'static,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let respond = Arc::new(respond);
let server = tokio::spawn(async move {
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let respond = Arc::clone(&respond);
tokio::spawn(async move {
let mut buf = [0u8; 8192];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let body = req.split("\r\n\r\n").nth(1).unwrap_or("");
let request: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => break,
};
let method = request["method"].as_str().unwrap_or_default();
let params = request
.get("params")
.cloned()
.unwrap_or(serde_json::Value::Null);
let Some(result) = respond(method, ¶ms) else {
tokio::time::sleep(Duration::from_secs(60)).await;
break;
};
let resp_body = serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"].clone(),
"result": result,
})
.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
resp_body.len(),
resp_body
);
if socket.write_all(resp.as_bytes()).await.is_err() {
break;
}
}
});
}
});
(addr, server)
}
#[tokio::test]
async fn structured_content_is_rendered_not_dropped() {
let (addr, server) = spawn_http_mock(|method, _| match method {
"server/discover" => Some(json!({
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {} },
"ttlMs": 0,
"cacheScope": "public",
})),
"tools/list" => Some(json!({
"tools": [{
"name": "summary",
"description": "structured",
"inputSchema": { "type": "object", "properties": {} },
}],
"nextCursor": null,
"ttlMs": 0,
"cacheScope": "public",
})),
"tools/call" => Some(json!({
"resultType": "complete",
"content": [{ "type": "text", "text": "hello" }],
"structuredContent": { "answer": 42 },
})),
_ => Some(json!({})),
});
let mut client = McpClient::from_url("s", format!("http://{addr}"));
let tools = client.tools().await.unwrap();
let state = crate::tool::SharedState::new();
let text = tools[0].call(json!({}), &state).await.unwrap();
assert!(text.starts_with("hello"));
assert!(text.contains("[structured]"));
assert!(text.contains("\"answer\""));
server.abort();
}
#[tokio::test]
async fn call_tool_hangs_returns_timeout_error() {
let (addr, server) = spawn_http_mock(|method, _| match method {
"server/discover" => Some(json!({
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {} },
"ttlMs": 0,
"cacheScope": "public",
})),
"tools/list" => Some(json!({
"tools": [{
"name": "hang",
"description": "hangs",
"inputSchema": { "type": "object", "properties": {} },
}],
"nextCursor": null,
"ttlMs": 0,
"cacheScope": "public",
})),
"tools/call" => None,
_ => Some(json!({})),
});
let mut client = McpClient::from_url("s", format!("http://{addr}"));
client.with_call_timeout(Duration::from_millis(100));
let tools = client.tools().await.unwrap();
let state = crate::tool::SharedState::new();
let err = tools[0].call(json!({}), &state).await.unwrap_err();
assert!(matches!(&err, ToolError::Execution(msg) if msg == "mcp tool call timed out"));
server.abort();
}
#[derive(Default)]
struct FakeServer;
fn tool_schema(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
value
.as_object()
.expect("tool schema must be an object")
.clone()
}
impl ServerHandler for FakeServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult {
tools: vec![
RmcpTool::new(
"echo",
"returns the text field verbatim",
tool_schema(json!({
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"],
})),
),
RmcpTool::new(
"fail",
"always fails at tool level",
tool_schema(json!({ "type": "object" })),
),
],
..Default::default()
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
match request.name.as_ref() {
"echo" => {
let text = request
.arguments
.as_ref()
.and_then(|args| args.get("text"))
.and_then(|v| v.as_str())
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(text.to_string())]).into())
}
"fail" => Ok(CallToolResult::error(vec![ContentBlock::text("boom")]).into()),
name => Err(ErrorData::invalid_params(
format!("unknown tool: {name}"),
None,
)),
}
}
}
#[test]
fn display_name_with_prefix_on() {
assert_eq!(tool_display_name("fs", "read_file", true), "fs__read_file");
}
#[test]
fn display_name_with_prefix_off() {
assert_eq!(tool_display_name("fs", "read_file", false), "read_file");
}
#[test]
fn content_to_text_joins_text_blocks() {
let blocks = vec![
ContentBlock::text("first line"),
ContentBlock::text("second line"),
];
assert_eq!(content_to_text(&blocks), "first line\nsecond line");
}
#[test]
fn content_to_text_placeholders_non_text() {
let blocks = vec![
ContentBlock::text("take a look at this:"),
ContentBlock::image("base64data", "image/png"),
ContentBlock::audio("base64data", "audio/wav"),
];
assert_eq!(
content_to_text(&blocks),
"take a look at this:\n[image: image/png]\n[audio: audio/wav]"
);
}
#[test]
fn content_to_text_empty() {
assert_eq!(content_to_text(&[]), "");
}
#[test]
fn map_tool_keeps_prefix_and_passthrough() {
let tool = RmcpTool::new(
"echo",
"description",
tool_schema(json!({ "type": "object" })),
);
let mapped = map_tool("fs", tool, true);
assert_eq!(mapped.name, "fs__echo");
assert_eq!(mapped.raw_name, "echo");
assert_eq!(mapped.description, "description");
assert_eq!(mapped.parameters, json!({ "type": "object" }));
}
#[tokio::test]
#[ignore]
async fn serve_as_fake_server() {
let running = serve_server(FakeServer, stdio()).await.unwrap();
running.waiting().await.unwrap();
std::process::exit(0);
}
fn fake_server_command() -> (String, Vec<String>) {
let exe = std::env::current_exe()
.unwrap()
.to_string_lossy()
.into_owned();
(exe, vec!["--ignored".into(), "--quiet".into()])
}
#[tokio::test]
async fn tools_roundtrip_echo_and_error() {
let (program, args) = fake_server_command();
let mut client = McpClient::from_command("fake", program, args);
let tools = client.tools().await.unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].schema().name, "fake__echo");
assert_eq!(tools[1].schema().name, "fake__fail");
let state = SharedState::new();
let echo = tools
.iter()
.find(|t| t.schema().name == "fake__echo")
.unwrap();
let result = echo.call(json!({ "text": "hello" }), &state).await.unwrap();
assert_eq!(result, "hello");
let fail = tools
.iter()
.find(|t| t.schema().name == "fake__fail")
.unwrap();
let err = fail.call(json!({}), &state).await.unwrap_err();
assert!(matches!(&err, ToolError::Execution(msg) if msg == "boom"));
let err = echo.call(json!([1, 2]), &state).await.unwrap_err();
assert!(matches!(err, ToolError::InvalidArguments(_)));
}
#[tokio::test]
async fn registry_assembly_with_prefix_off() {
let (program, args) = fake_server_command();
let mut client = McpClient::from_command("fake", program, args);
client.with_name_prefix(false);
let mut registry = ToolRegistry::new();
for tool in client.tools().await.unwrap() {
registry.register(tool);
}
assert_eq!(registry.names(), vec!["echo", "fail"]);
let result = registry
.call("echo", r#"{"text":"hi"}"#, &SharedState::new())
.await
.unwrap();
assert_eq!(result, "hi");
}
#[tokio::test]
async fn cleanup_then_tools_reconnects() {
let (program, args) = fake_server_command();
let mut client = McpClient::from_command("fake", program, args);
assert_eq!(client.tools().await.unwrap().len(), 2);
client.cleanup().await.unwrap();
assert_eq!(client.tools().await.unwrap().len(), 2);
}
#[tokio::test]
async fn connect_is_idempotent() {
let (program, args) = fake_server_command();
let mut client = McpClient::from_command("fake", program, args);
client.connect().await.unwrap();
client.connect().await.unwrap(); assert_eq!(client.tools().await.unwrap().len(), 2);
}
#[tokio::test]
async fn args_spawn_failure_restores_spec_for_retry() {
let mut client = McpClient::from_command(
"ghost",
"molo-no-such-program-xyz",
std::iter::empty::<&str>(),
);
let err = client.connect().await.unwrap_err();
assert!(
matches!(&err, McpError::Connect { message, .. } if message.contains("spawn failed"))
);
let err2 = client.connect().await.unwrap_err();
assert!(
matches!(&err2, McpError::Connect { message, .. } if message.contains("spawn failed")),
"spec must be restored after spawn failure; retry should fail with spawn error, not already consumed, got: {err2}"
);
}
#[tokio::test]
async fn configured_command_is_one_shot_and_guides_rebuild() {
let (program, _) = fake_server_command();
let mut command = std::process::Command::new(program);
command.args(["--ignored", "--quiet"]);
let mut client = McpClient::from_command_configured("fake", command);
assert_eq!(client.tools().await.unwrap().len(), 2);
client.cleanup().await.unwrap();
let err = client.tools().await.unwrap_err();
assert!(matches!(&err, McpError::Connect { message, .. }
if message.contains("create a new McpClient")));
}
#[tokio::test]
async fn from_url_connect_failure_reports_connect_error() {
let mut client = McpClient::from_url("nowhere", "http://127.0.0.1:1/mcp");
let err = client.connect().await.unwrap_err();
assert!(matches!(&err, McpError::Connect { server, .. } if server == "nowhere"));
assert!(
err.to_string()
.starts_with("mcp connect failed (server nowhere)")
);
}
}