1use std::hash::{Hash, Hasher};
53use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
54use std::time::{Duration, Instant};
55
56use dashmap::DashMap;
57use rustc_hash::FxHasher;
58use serde_json::Value;
59
60use std::sync::Arc;
61
62use crate::error::{McpError, Result};
63use crate::retry::{retry_mcp_call, McpRetryConfig};
64use crate::rmcp_adapter::RmcpClientAdapter;
65use crate::types::{ContentBlock, McpConfig, ResourceContent, ToolCallResult, ToolDefinition};
66use crate::validation::{ErrorEnhancer, McpValidator, ValidationConfig, ValidationErrorKind};
67use nika_event::{EventKind, EventLog};
68
69#[derive(Debug, Clone)]
75pub struct McpPingResult {
76 pub server: String,
78
79 pub latency: Duration,
81
82 pub tool_count: usize,
84
85 pub was_connected: bool,
87}
88
89#[derive(Debug, Clone)]
91pub enum McpPingError {
92 StartFailed { server: String, details: String },
94
95 Timeout { server: String, timeout: Duration },
97
98 ConnectionRefused { server: String },
100
101 ServerError { server: String, details: String },
103}
104
105impl std::fmt::Display for McpPingError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match self {
108 McpPingError::StartFailed { server, details } => {
109 write!(f, "MCP server '{}' failed to start: {}", server, details)
110 }
111 McpPingError::Timeout { server, timeout } => {
112 write!(f, "MCP server '{}' timed out after {:?}", server, timeout)
113 }
114 McpPingError::ConnectionRefused { server } => {
115 write!(f, "MCP server '{}' connection refused", server)
116 }
117 McpPingError::ServerError { server, details } => {
118 write!(f, "MCP server '{}' error: {}", server, details)
119 }
120 }
121 }
122}
123
124impl McpPingError {
125 pub fn suggestion(&self) -> &'static str {
127 match self {
128 McpPingError::StartFailed { .. } => {
129 "Check the MCP server command is correct and the executable exists"
130 }
131 McpPingError::Timeout { .. } => {
132 "The MCP server may be slow to start. Try increasing the timeout"
133 }
134 McpPingError::ConnectionRefused { .. } => {
135 "Ensure the MCP server is running and accessible"
136 }
137 McpPingError::ServerError { .. } => "Check the MCP server logs for more details",
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
159pub struct CacheConfig {
160 pub ttl: Duration,
162
163 pub max_entries: usize,
165}
166
167impl Default for CacheConfig {
168 fn default() -> Self {
169 Self {
170 ttl: Duration::from_secs(300), max_entries: 1000,
172 }
173 }
174}
175
176#[derive(Debug, Clone)]
182struct CacheEntry {
183 result: Arc<ToolCallResult>,
185
186 created_at: Instant,
188}
189
190impl CacheEntry {
191 fn new(result: Arc<ToolCallResult>) -> Self {
192 Self {
193 result,
194 created_at: Instant::now(),
195 }
196 }
197
198 fn is_expired(&self, ttl: Duration) -> bool {
199 self.created_at.elapsed() > ttl
200 }
201}
202
203#[derive(Debug)]
207struct ResponseCache {
208 config: CacheConfig,
210
211 entries: DashMap<String, CacheEntry, rustc_hash::FxBuildHasher>,
213
214 hits: AtomicU64,
216
217 misses: AtomicU64,
219}
220
221impl ResponseCache {
222 fn new(config: CacheConfig) -> Self {
223 Self {
224 config,
225 entries: DashMap::default(),
226 hits: AtomicU64::new(0),
227 misses: AtomicU64::new(0),
228 }
229 }
230
231 fn cache_key(tool: &str, params: &Value) -> String {
236 let mut hasher = FxHasher::default();
237 let canonical = Self::canonicalize_value(params);
239 let params_str = match serde_json::to_string(&canonical) {
240 Ok(s) => s,
241 Err(e) => {
242 tracing::warn!(
243 tool = tool,
244 error = %e,
245 "JSON serialization failed for cache key, using Debug format"
246 );
247 format!("{:?}", params)
248 }
249 };
250 params_str.hash(&mut hasher);
251 format!("{}:{:016x}", tool, hasher.finish())
252 }
253
254 const MAX_CANONICALIZE_DEPTH: usize = 128;
256
257 fn canonicalize_value(value: &Value) -> Value {
262 Self::canonicalize_value_inner(value, 0)
263 }
264
265 fn canonicalize_value_inner(value: &Value, depth: usize) -> Value {
266 if depth >= Self::MAX_CANONICALIZE_DEPTH {
267 return value.clone();
268 }
269 match value {
270 Value::Object(map) => {
271 let mut sorted: serde_json::Map<String, Value> = serde_json::Map::new();
272 let mut keys: Vec<&String> = map.keys().collect();
273 keys.sort();
274 for key in keys {
275 sorted.insert(
276 key.clone(),
277 Self::canonicalize_value_inner(&map[key], depth + 1),
278 );
279 }
280 Value::Object(sorted)
281 }
282 Value::Array(arr) => Value::Array(
283 arr.iter()
284 .map(|v| Self::canonicalize_value_inner(v, depth + 1))
285 .collect(),
286 ),
287 other => other.clone(),
288 }
289 }
290
291 fn get(&self, tool: &str, params: &Value) -> Option<Arc<ToolCallResult>> {
296 let key = Self::cache_key(tool, params);
297
298 if let Some(entry) = self.entries.get(&key) {
299 if entry.is_expired(self.config.ttl) {
300 drop(entry);
302 self.entries.remove(&key);
303 self.misses.fetch_add(1, Ordering::Relaxed);
304 return None;
305 }
306
307 self.hits.fetch_add(1, Ordering::Relaxed);
308 return Some(Arc::clone(&entry.result));
309 }
310
311 self.misses.fetch_add(1, Ordering::Relaxed);
312 None
313 }
314
315 fn put(&self, tool: &str, params: &Value, result: ToolCallResult) {
319 if result.is_error {
321 return;
322 }
323
324 let key = Self::cache_key(tool, params);
325
326 if self.entries.len() >= self.config.max_entries {
328 self.evict_oldest();
329 }
330
331 self.entries.insert(key, CacheEntry::new(Arc::new(result)));
332 }
333
334 fn evict_oldest(&self) {
339 let to_remove = (self.config.max_entries / 10).max(1);
340 let mut entries: Vec<(String, Instant)> = self
341 .entries
342 .iter()
343 .map(|e| (e.key().clone(), e.created_at))
344 .collect();
345
346 if entries.len() <= to_remove {
347 for (key, _) in &entries {
349 self.entries.remove(key);
350 }
351 return;
352 }
353
354 entries.select_nth_unstable_by_key(to_remove - 1, |(_, created)| *created);
356
357 for (key, _) in entries.iter().take(to_remove) {
358 self.entries.remove(key);
359 }
360 }
361
362 fn clear(&self) {
364 self.entries.clear();
365 self.hits.store(0, Ordering::Relaxed);
366 self.misses.store(0, Ordering::Relaxed);
367 }
368
369 fn stats(&self) -> ResponseCacheStats {
371 ResponseCacheStats {
372 entries: self.entries.len(),
373 hits: self.hits.load(Ordering::Relaxed),
374 misses: self.misses.load(Ordering::Relaxed),
375 }
376 }
377}
378
379#[derive(Debug, Clone, Default)]
381pub struct ResponseCacheStats {
382 pub entries: usize,
384
385 pub hits: u64,
387
388 pub misses: u64,
390}
391
392impl ResponseCacheStats {
393 pub fn hit_rate(&self) -> f64 {
395 let total = self.hits + self.misses;
396 if total == 0 {
397 0.0
398 } else {
399 self.hits as f64 / total as f64
400 }
401 }
402}
403
404pub struct McpClient {
424 name: String,
426
427 connected: AtomicBool,
431
432 is_mock: bool,
434
435 adapter: Option<RmcpClientAdapter>,
437
438 validator: Option<McpValidator>,
440
441 cache: Option<ResponseCache>,
443
444 last_cache_hit: AtomicBool,
446}
447
448impl std::fmt::Debug for McpClient {
449 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 f.debug_struct("McpClient")
451 .field("name", &self.name)
452 .field("connected", &self.connected)
453 .field("is_mock", &self.is_mock)
454 .field("has_adapter", &self.adapter.is_some())
455 .field("has_validator", &self.validator.is_some())
456 .field("has_cache", &self.cache.is_some())
457 .field("last_cache_hit", &self.last_cache_hit)
458 .finish()
459 }
460}
461
462impl McpClient {
463 pub fn new(config: McpConfig) -> Result<Self> {
483 if config.name.is_empty() {
485 return Err(McpError::ValidationError {
486 reason: "MCP server name cannot be empty".to_string(),
487 });
488 }
489
490 if config.command.is_empty() {
491 return Err(McpError::ValidationError {
492 reason: "MCP server command cannot be empty".to_string(),
493 });
494 }
495
496 let name = config.name.clone();
497 let adapter = RmcpClientAdapter::new(config);
498
499 Ok(Self {
500 name,
501 connected: AtomicBool::new(false),
502 is_mock: false,
503 adapter: Some(adapter),
504 validator: None,
505 cache: None,
506 last_cache_hit: AtomicBool::new(false),
507 })
508 }
509
510 pub fn with_validation(mut self, config: ValidationConfig) -> Self {
524 self.validator = Some(McpValidator::new(config));
525 self
526 }
527
528 pub fn with_cache(mut self, config: CacheConfig) -> Self {
548 self.cache = Some(ResponseCache::new(config));
549 self
550 }
551
552 pub fn cache_stats(&self) -> Option<ResponseCacheStats> {
556 self.cache.as_ref().map(|c| c.stats())
557 }
558
559 pub fn was_last_call_cached(&self) -> bool {
564 self.last_cache_hit.load(Ordering::SeqCst)
565 }
566
567 pub fn mock(name: &str) -> Self {
581 Self {
582 name: name.to_string(),
583 connected: AtomicBool::new(true), is_mock: true,
585 adapter: None,
586 validator: None,
587 cache: None,
588 last_cache_hit: AtomicBool::new(false),
589 }
590 }
591
592 pub fn name(&self) -> &str {
594 &self.name
595 }
596
597 pub fn is_connected(&self) -> bool {
602 if self.is_mock {
603 return self.connected.load(Ordering::SeqCst);
604 }
605 self.adapter
607 .as_ref()
608 .map(|a| a.is_connected_sync())
609 .unwrap_or(false)
610 }
611
612 pub async fn is_connected_async(&self) -> bool {
614 if self.is_mock {
615 return self.connected.load(Ordering::SeqCst);
616 }
617 if let Some(adapter) = &self.adapter {
618 adapter.is_connected().await
619 } else {
620 false
621 }
622 }
623
624 pub async fn ping(&self) -> std::result::Result<McpPingResult, McpPingError> {
639 let start = Instant::now();
640 let was_connected = self.is_connected_async().await;
641
642 if self.is_mock {
644 return Ok(McpPingResult {
645 server: self.name.clone(),
646 latency: start.elapsed(),
647 tool_count: self.mock_list_tools().len(),
648 was_connected: true,
649 });
650 }
651
652 if !was_connected {
654 if let Err(e) = self.connect().await {
655 let error_msg = e.to_string().to_lowercase();
656 if error_msg.contains("refused") || error_msg.contains("connection") {
657 return Err(McpPingError::ConnectionRefused {
658 server: self.name.clone(),
659 });
660 }
661 return Err(McpPingError::StartFailed {
662 server: self.name.clone(),
663 details: e.to_string(),
664 });
665 }
666 }
667
668 match tokio::time::timeout(Duration::from_secs(10), self.list_tools()).await {
670 Ok(Ok(tools)) => Ok(McpPingResult {
671 server: self.name.clone(),
672 latency: start.elapsed(),
673 tool_count: tools.len(),
674 was_connected,
675 }),
676 Ok(Err(e)) => Err(McpPingError::ServerError {
677 server: self.name.clone(),
678 details: e.to_string(),
679 }),
680 Err(_) => Err(McpPingError::Timeout {
681 server: self.name.clone(),
682 timeout: Duration::from_secs(10),
683 }),
684 }
685 }
686
687 pub fn is_configured(&self) -> bool {
696 self.is_mock || self.adapter.is_some()
697 }
698
699 pub async fn connect(&self) -> Result<()> {
713 if self.is_mock {
714 self.connected.store(true, Ordering::SeqCst);
715 if let Some(ref validator) = self.validator {
717 let tools = self.mock_list_tools();
718 validator
719 .cache()
720 .populate(&self.name, &tools)
721 .map_err(|e| McpError::McpSchemaError {
722 tool: "*".to_string(),
723 reason: format!("Failed to cache mock tool schemas: {}", e),
724 })?;
725 }
726 return Ok(());
727 }
728
729 let adapter = self
730 .adapter
731 .as_ref()
732 .ok_or_else(|| McpError::McpNotConnected {
733 name: self.name.clone(),
734 })?;
735
736 adapter.connect().await?;
737 self.connected.store(true, Ordering::SeqCst);
738
739 if let Some(ref validator) = self.validator {
741 let tools = adapter.list_tools().await?;
742 validator
743 .cache()
744 .populate(&self.name, &tools)
745 .map_err(|e| McpError::McpSchemaError {
746 tool: "*".to_string(),
747 reason: format!("Failed to cache tool schemas: {}", e),
748 })?;
749 tracing::debug!(
750 mcp_server = %self.name,
751 tools_cached = tools.len(),
752 "Cached tool schemas for validation"
753 );
754 }
755
756 Ok(())
757 }
758
759 pub async fn disconnect(&self) -> Result<()> {
766 if self.is_mock {
767 self.connected.store(false, Ordering::SeqCst);
768 return Ok(());
769 }
770
771 if let Some(adapter) = &self.adapter {
772 adapter.disconnect().await?;
773 }
774
775 if let Some(ref cache) = self.cache {
777 cache.clear();
778 }
779
780 if let Some(ref validator) = self.validator {
782 validator.cache().clear();
783 }
784
785 self.connected.store(false, Ordering::SeqCst);
786 Ok(())
787 }
788
789 pub async fn reconnect(&self) -> Result<()> {
806 if self.is_mock {
807 self.connected.store(true, Ordering::SeqCst);
808 return Ok(());
809 }
810
811 self.disconnect().await?;
813
814 let adapter = self
816 .adapter
817 .as_ref()
818 .ok_or_else(|| McpError::McpNotConnected {
819 name: self.name.clone(),
820 })?;
821
822 adapter.reconnect().await?;
823 self.connected.store(true, Ordering::SeqCst);
824 Ok(())
825 }
826
827 pub fn is_connection_error(error: &McpError) -> bool {
832 let error_str = error.to_string().to_lowercase();
833 error_str.contains("broken pipe")
834 || error_str.contains("connection reset")
835 || error_str.contains("connection refused")
836 || error_str.contains("eof")
837 || error_str.contains("stdin not available")
838 || error_str.contains("stdout not available")
839 || error_str.contains("transport closed")
840 || error_str.contains("transport send")
841 }
842
843 fn enhance_error(&self, tool_name: &str, error: McpError) -> McpError {
845 if let Some(ref validator) = self.validator {
846 if validator.config().enhance_errors {
847 let enhancer = ErrorEnhancer::new(validator.cache());
848 return enhancer.enhance(&self.name, tool_name, error);
849 }
850 }
851 error
852 }
853
854 pub async fn call_tool(&self, name: &str, params: Value) -> Result<ToolCallResult> {
883 if let Some(ref validator) = self.validator {
885 if validator.config().pre_validate {
886 let result = validator.validate(&self.name, name, ¶ms);
887 if !result.is_valid {
888 let missing: Vec<String> = result
890 .errors
891 .iter()
892 .filter_map(|e| {
893 if let ValidationErrorKind::MissingRequired { field } = &e.kind {
894 Some(field.clone())
895 } else {
896 None
897 }
898 })
899 .collect();
900
901 let suggestions: Vec<String> = result
902 .errors
903 .iter()
904 .filter_map(|e| {
905 if let ValidationErrorKind::UnknownField { suggestions, .. } = &e.kind {
906 Some(suggestions.clone())
907 } else {
908 None
909 }
910 })
911 .flatten()
912 .collect();
913
914 let details = result
915 .errors
916 .iter()
917 .map(|e| e.message.clone())
918 .collect::<Vec<_>>()
919 .join("; ");
920
921 return Err(McpError::McpValidationFailed {
922 tool: name.to_string(),
923 details,
924 missing,
925 suggestions,
926 });
927 }
928 }
929 }
930
931 if let Some(ref cache) = self.cache {
933 if let Some(cached_result) = cache.get(name, ¶ms) {
934 self.last_cache_hit.store(true, Ordering::SeqCst);
935 tracing::debug!(
936 mcp_server = %self.name,
937 tool = %name,
938 "Cache hit for MCP tool call"
939 );
940 return Ok((*cached_result).clone());
941 }
942 }
943
944 self.last_cache_hit.store(false, Ordering::SeqCst);
946
947 if self.is_mock {
948 if !self.connected.load(Ordering::SeqCst) {
949 return Err(McpError::McpNotConnected {
950 name: self.name.clone(),
951 });
952 }
953 let result = self.mock_tool_call(name, ¶ms);
954 if let Some(ref cache) = self.cache {
956 cache.put(name, ¶ms, result.clone());
957 }
958 return Ok(result);
959 }
960
961 let adapter = self
963 .adapter
964 .as_ref()
965 .ok_or_else(|| McpError::McpNotConnected {
966 name: self.name.clone(),
967 })?;
968
969 let result = retry_mcp_call(McpRetryConfig::default(), || {
970 let params = params.clone();
971 async move {
972 match adapter.call_tool(name, params).await {
973 Ok(result) => Ok(result),
974 Err(e) => {
975 let enhanced = self.enhance_error(name, e);
976 if Self::is_connection_error(&enhanced) {
978 tracing::warn!(
979 mcp_server = %self.name,
980 tool = %name,
981 error = %enhanced,
982 "Connection error, attempting reconnect"
983 );
984 if let Err(reconnect_err) = adapter.reconnect().await {
985 tracing::error!(
986 mcp_server = %self.name,
987 error = %reconnect_err,
988 "Failed to reconnect"
989 );
990 }
991 }
992 Err(enhanced)
993 }
994 }
995 }
996 })
997 .await?;
998
999 if let Some(ref cache) = self.cache {
1001 cache.put(name, ¶ms, result.clone());
1002 tracing::debug!(
1003 mcp_server = %self.name,
1004 tool = %name,
1005 "Cached MCP tool response"
1006 );
1007 }
1008 Ok(result)
1009 }
1010
1011 pub async fn call_tool_with_retry_events(
1035 &self,
1036 name: &str,
1037 params: Value,
1038 task_id: &Arc<str>,
1039 event_log: &EventLog,
1040 ) -> Result<ToolCallResult> {
1041 if let Some(ref validator) = self.validator {
1043 if validator.config().pre_validate {
1044 let result = validator.validate(&self.name, name, ¶ms);
1045 if !result.is_valid {
1046 let missing: Vec<String> = result
1047 .errors
1048 .iter()
1049 .filter_map(|e| {
1050 if let ValidationErrorKind::MissingRequired { field } = &e.kind {
1051 Some(field.clone())
1052 } else {
1053 None
1054 }
1055 })
1056 .collect();
1057
1058 let suggestions: Vec<String> = result
1059 .errors
1060 .iter()
1061 .filter_map(|e| {
1062 if let ValidationErrorKind::UnknownField { suggestions, .. } = &e.kind {
1063 Some(suggestions.clone())
1064 } else {
1065 None
1066 }
1067 })
1068 .flatten()
1069 .collect();
1070
1071 let details = result
1072 .errors
1073 .iter()
1074 .map(|e| e.message.clone())
1075 .collect::<Vec<_>>()
1076 .join("; ");
1077
1078 return Err(McpError::McpValidationFailed {
1079 tool: name.to_string(),
1080 details,
1081 missing,
1082 suggestions,
1083 });
1084 }
1085 }
1086 }
1087
1088 if let Some(ref cache) = self.cache {
1090 if let Some(cached_result) = cache.get(name, ¶ms) {
1091 self.last_cache_hit.store(true, Ordering::SeqCst);
1092 tracing::debug!(
1093 mcp_server = %self.name,
1094 tool = %name,
1095 "Cache hit for MCP tool call"
1096 );
1097 return Ok((*cached_result).clone());
1098 }
1099 }
1100
1101 self.last_cache_hit.store(false, Ordering::SeqCst);
1102
1103 if self.is_mock {
1104 if !self.connected.load(Ordering::SeqCst) {
1105 return Err(McpError::McpNotConnected {
1106 name: self.name.clone(),
1107 });
1108 }
1109 let result = self.mock_tool_call(name, ¶ms);
1110 if let Some(ref cache) = self.cache {
1111 cache.put(name, ¶ms, result.clone());
1112 }
1113 return Ok(result);
1114 }
1115
1116 let adapter = self
1118 .adapter
1119 .as_ref()
1120 .ok_or_else(|| McpError::McpNotConnected {
1121 name: self.name.clone(),
1122 })?;
1123
1124 let config = McpRetryConfig::default();
1125 let max_attempts = config.max_retries + 1; let attempt_counter = std::sync::atomic::AtomicU32::new(0);
1127
1128 let result = retry_mcp_call(config, || {
1129 let params = params.clone();
1130 async {
1131 let attempt = attempt_counter.fetch_add(1, Ordering::SeqCst);
1132 match adapter.call_tool(name, params).await {
1133 Ok(result) => Ok(result),
1134 Err(e) => {
1135 let enhanced = self.enhance_error(name, e);
1136 if Self::is_connection_error(&enhanced) {
1137 event_log.emit(EventKind::McpRetry {
1139 task_id: Arc::clone(task_id),
1140 server_name: self.name.clone(),
1141 operation: name.to_string(),
1142 attempt: attempt + 1,
1143 max_attempts: max_attempts as u32,
1144 error: enhanced.to_string(),
1145 });
1146 tracing::warn!(
1147 mcp_server = %self.name,
1148 tool = %name,
1149 attempt = attempt + 1,
1150 error = %enhanced,
1151 "Connection error, attempting reconnect (McpRetry event emitted)"
1152 );
1153 if let Err(reconnect_err) = adapter.reconnect().await {
1154 tracing::error!(
1155 mcp_server = %self.name,
1156 error = %reconnect_err,
1157 "Failed to reconnect"
1158 );
1159 }
1160 }
1161 Err(enhanced)
1162 }
1163 }
1164 }
1165 })
1166 .await?;
1167
1168 if let Some(ref cache) = self.cache {
1170 cache.put(name, ¶ms, result.clone());
1171 tracing::debug!(
1172 mcp_server = %self.name,
1173 tool = %name,
1174 "Cached MCP tool response"
1175 );
1176 }
1177 Ok(result)
1178 }
1179
1180 pub async fn read_resource(&self, uri: &str) -> Result<ResourceContent> {
1197 if self.is_mock {
1198 if !self.connected.load(Ordering::SeqCst) {
1199 return Err(McpError::McpNotConnected {
1200 name: self.name.clone(),
1201 });
1202 }
1203 return Ok(self.mock_read_resource(uri));
1204 }
1205
1206 let adapter = self
1208 .adapter
1209 .as_ref()
1210 .ok_or_else(|| McpError::McpNotConnected {
1211 name: self.name.clone(),
1212 })?;
1213
1214 retry_mcp_call(McpRetryConfig::default(), || {
1215 async move {
1216 match adapter.read_resource(uri).await {
1217 Ok(result) => Ok(result),
1218 Err(e) => {
1219 if Self::is_connection_error(&e) {
1221 tracing::warn!(
1222 mcp_server = %self.name,
1223 uri = %uri,
1224 error = %e,
1225 "Connection error, attempting reconnect"
1226 );
1227 if let Err(reconnect_err) = adapter.reconnect().await {
1228 tracing::error!(
1229 mcp_server = %self.name,
1230 error = %reconnect_err,
1231 "Failed to reconnect"
1232 );
1233 }
1234 }
1235 Err(e)
1236 }
1237 }
1238 }
1239 })
1240 .await
1241 }
1242
1243 pub async fn list_tools(&self) -> Result<Vec<ToolDefinition>> {
1258 if self.is_mock {
1259 if !self.connected.load(Ordering::SeqCst) {
1260 return Err(McpError::McpNotConnected {
1261 name: self.name.clone(),
1262 });
1263 }
1264 return Ok(self.mock_list_tools());
1265 }
1266
1267 let adapter = self
1269 .adapter
1270 .as_ref()
1271 .ok_or_else(|| McpError::McpNotConnected {
1272 name: self.name.clone(),
1273 })?;
1274
1275 adapter.list_tools().await
1276 }
1277
1278 fn mock_tool_call(&self, name: &str, params: &Value) -> ToolCallResult {
1284 match name {
1285 "novanet_describe" => {
1286 let response = serde_json::json!({
1287 "nodes": 61,
1288 "arcs": 182,
1289 "labels": ["Entity", "EntityNative", "Page", "Block"],
1290 "relationships": ["HAS_NATIVE", "CONTAINS", "FLOWS_TO"]
1291 });
1292 ToolCallResult::success(vec![ContentBlock::text(response.to_string())])
1293 }
1294
1295 "novanet_context" => {
1296 let entity = params
1298 .get("focus_key")
1299 .or_else(|| params.get("entity"))
1300 .and_then(|v| v.as_str())
1301 .unwrap_or("unknown");
1302 let locale = params
1303 .get("locale")
1304 .and_then(|v| v.as_str())
1305 .unwrap_or("en-US");
1306
1307 let response = serde_json::json!({
1308 "entity": entity,
1309 "locale": locale,
1310 "context": {
1311 "title": format!("{} - Generated Title", entity),
1312 "description": format!("Auto-generated content for {} in {}", entity, locale),
1313 "keywords": ["generated", "mock", entity]
1314 }
1315 });
1316 ToolCallResult::success(vec![ContentBlock::text(response.to_string())])
1317 }
1318
1319 _ => {
1320 let response = serde_json::json!({
1322 "tool": name,
1323 "status": "success",
1324 "message": "Mock tool call completed"
1325 });
1326 ToolCallResult::success(vec![ContentBlock::text(response.to_string())])
1327 }
1328 }
1329 }
1330
1331 fn mock_read_resource(&self, uri: &str) -> ResourceContent {
1333 let text = if uri.starts_with("neo4j://entity/") {
1335 let entity = uri.strip_prefix("neo4j://entity/").unwrap_or("unknown");
1336 serde_json::json!({
1337 "id": entity,
1338 "type": "Entity",
1339 "properties": {
1340 "name": entity,
1341 "created": "2024-01-01T00:00:00Z"
1342 }
1343 })
1344 .to_string()
1345 } else if uri.starts_with("file://") {
1346 "Mock file content".to_string()
1347 } else {
1348 serde_json::json!({
1349 "uri": uri,
1350 "content": "Mock resource content"
1351 })
1352 .to_string()
1353 };
1354
1355 ResourceContent::new(uri)
1356 .with_mime_type("application/json")
1357 .with_text(text)
1358 }
1359
1360 pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
1371 if self.is_mock {
1372 self.mock_list_tools()
1373 } else if let Some(ref adapter) = self.adapter {
1374 adapter.get_cached_tools()
1375 } else {
1376 Vec::new()
1377 }
1378 }
1379
1380 pub fn is_tool_cache_fresh(&self, ttl: std::time::Duration) -> bool {
1385 if self.is_mock {
1386 true
1387 } else if let Some(ref adapter) = self.adapter {
1388 adapter.is_tool_cache_fresh(ttl)
1389 } else {
1390 false
1391 }
1392 }
1393
1394 pub fn invalidate_tool_cache(&self) {
1398 if !self.is_mock {
1399 if let Some(ref adapter) = self.adapter {
1400 adapter.invalidate_tool_cache();
1401 }
1402 }
1403 }
1404
1405 fn mock_list_tools(&self) -> Vec<ToolDefinition> {
1407 vec![
1408 ToolDefinition::new("novanet_describe")
1409 .with_description("Bootstrap understanding of the graph"),
1410 ToolDefinition::new("novanet_search")
1411 .with_description("Find nodes via 5 modes: fulltext, property, hybrid, walk, triggers"),
1412 ToolDefinition::new("novanet_context")
1413 .with_description("Unified context assembly for LLM content generation")
1414 .with_input_schema(serde_json::json!({
1415 "type": "object",
1416 "properties": {
1417 "mode": {"type": "string", "description": "Context mode (page, block, knowledge, assemble)"},
1418 "focus_key": {"type": "string", "description": "Focus node key"},
1419 "locale": {"type": "string", "description": "Target locale (e.g., fr-FR)"}
1420 },
1421 "required": ["mode", "locale"]
1422 })),
1423 ]
1424 }
1425}
1426
1427#[cfg(test)]
1430mod tests {
1431 use super::*;
1432
1433 #[tokio::test]
1438 async fn test_multiple_sequential_calls() {
1439 let client = McpClient::mock("test");
1441
1442 for i in 0..10 {
1443 let result = client
1444 .call_tool("test_tool", serde_json::json!({"iteration": i}))
1445 .await;
1446 assert!(
1447 result.is_ok(),
1448 "Call {} should succeed: {:?}",
1449 i,
1450 result.err()
1451 );
1452 }
1453 }
1454
1455 #[tokio::test]
1456 async fn test_concurrent_calls() {
1457 let client = std::sync::Arc::new(McpClient::mock("test"));
1459
1460 let handles: Vec<_> = (0..20)
1461 .map(|i| {
1462 let client = std::sync::Arc::clone(&client);
1463 tokio::spawn(async move {
1464 client
1465 .call_tool("test_tool", serde_json::json!({"iteration": i}))
1466 .await
1467 })
1468 })
1469 .collect();
1470
1471 for (i, handle) in handles.into_iter().enumerate() {
1472 let result = handle.await.expect("Task should not panic");
1473 assert!(result.is_ok(), "Concurrent call {} should succeed", i);
1474 }
1475 }
1476
1477 #[test]
1482 fn test_client_name_accessor() {
1483 let config = McpConfig::new("test-server", "echo");
1484 let client = McpClient::new(config).unwrap();
1485 assert_eq!(client.name(), "test-server");
1486 }
1487
1488 #[test]
1489 fn test_mock_client_is_pre_connected() {
1490 let client = McpClient::mock("test");
1491 assert!(client.is_connected());
1492 assert!(client.is_mock);
1493 }
1494
1495 #[test]
1496 fn test_real_client_starts_disconnected() {
1497 let config = McpConfig::new("test", "echo");
1498 let client = McpClient::new(config).unwrap();
1499 assert!(!client.is_connected());
1500 assert!(!client.is_mock);
1501 }
1502
1503 #[tokio::test]
1504 async fn test_mock_tool_call_returns_success() {
1505 let client = McpClient::mock("test");
1506 let result = client
1507 .call_tool("unknown_tool", serde_json::json!({}))
1508 .await;
1509 assert!(result.is_ok());
1510 assert!(!result.unwrap().is_error);
1511 }
1512
1513 #[tokio::test]
1518 async fn test_mock_read_resource_entity() {
1519 let client = McpClient::mock("test");
1520 let result = client.read_resource("neo4j://entity/qr-code").await;
1521 assert!(result.is_ok());
1522
1523 let resource = result.unwrap();
1524 assert_eq!(resource.uri, "neo4j://entity/qr-code");
1525 assert!(resource.text.is_some());
1526 }
1527
1528 #[tokio::test]
1529 async fn test_mock_read_resource_file() {
1530 let client = McpClient::mock("test");
1531 let result = client.read_resource("file:///tmp/test.txt").await;
1532 assert!(result.is_ok());
1533
1534 let resource = result.unwrap();
1535 assert_eq!(resource.uri, "file:///tmp/test.txt");
1536 }
1537
1538 #[test]
1543 fn test_mock_client_drop_is_noop() {
1544 let client = McpClient::mock("test");
1546 assert!(client.is_mock);
1547 drop(client);
1549 }
1550
1551 #[test]
1552 fn test_real_client_drop_without_process() {
1553 let config = McpConfig::new("test", "echo");
1555 let client = McpClient::new(config).unwrap();
1556 assert!(!client.is_mock);
1557 drop(client);
1559 }
1560
1561 #[test]
1566 fn test_with_validation_enables_validator() {
1567 let config = McpConfig::new("test", "echo");
1568 let client = McpClient::new(config)
1569 .unwrap()
1570 .with_validation(ValidationConfig::default());
1571
1572 assert!(client.validator.is_some());
1574 }
1575
1576 #[tokio::test]
1577 async fn test_mock_connect_populates_schema_cache_when_validation_enabled() {
1578 let client = McpClient::mock("novanet").with_validation(ValidationConfig::default());
1579
1580 client.connect().await.unwrap();
1582
1583 let validator = client.validator.as_ref().unwrap();
1585 let stats = validator.cache().stats();
1586 assert!(stats.tool_count > 0, "Should have cached tools");
1587 }
1588
1589 #[tokio::test]
1590 async fn test_call_tool_validates_missing_required_field() {
1591 let client = McpClient::mock("novanet").with_validation(ValidationConfig::default());
1592 client.connect().await.unwrap();
1593
1594 let result = client
1596 .call_tool(
1597 "novanet_context",
1598 serde_json::json!({
1599 "focus_key": "qr-code"
1600 }),
1602 )
1603 .await;
1604
1605 assert!(result.is_err());
1606 let err = result.unwrap_err();
1607 assert!(matches!(err, McpError::McpValidationFailed { .. }));
1608
1609 if let McpError::McpValidationFailed {
1610 missing, details, ..
1611 } = err
1612 {
1613 assert!(missing.contains(&"mode".to_string()));
1614 assert!(details.contains("mode"));
1615 }
1616 }
1617
1618 #[tokio::test]
1619 async fn test_call_tool_passes_validation_with_valid_params() {
1620 let client = McpClient::mock("novanet").with_validation(ValidationConfig::default());
1621 client.connect().await.unwrap();
1622
1623 let result = client
1625 .call_tool(
1626 "novanet_context",
1627 serde_json::json!({
1628 "mode": "page",
1629 "focus_key": "qr-code",
1630 "locale": "fr-FR"
1631 }),
1632 )
1633 .await;
1634
1635 assert!(result.is_ok());
1636 }
1637
1638 #[tokio::test]
1639 async fn test_call_tool_skips_validation_when_disabled() {
1640 let config = ValidationConfig {
1641 pre_validate: false, ..Default::default()
1643 };
1644 let client = McpClient::mock("novanet").with_validation(config);
1645 client.connect().await.unwrap();
1646
1647 let result = client
1649 .call_tool(
1650 "novanet_context",
1651 serde_json::json!({
1652 "focus_key": "qr-code"
1653 }),
1655 )
1656 .await;
1657
1658 assert!(result.is_ok());
1660 }
1661
1662 #[tokio::test]
1663 async fn test_call_tool_without_validation_works() {
1664 let client = McpClient::mock("novanet");
1666
1667 let result = client
1669 .call_tool(
1670 "novanet_context",
1671 serde_json::json!({
1672 }),
1674 )
1675 .await;
1676
1677 assert!(result.is_ok());
1678 }
1679
1680 #[tokio::test]
1681 async fn test_validation_for_unknown_tool_passes() {
1682 let client = McpClient::mock("novanet").with_validation(ValidationConfig::default());
1683 client.connect().await.unwrap();
1684
1685 let result = client
1687 .call_tool(
1688 "unknown_tool",
1689 serde_json::json!({
1690 "anything": "goes"
1691 }),
1692 )
1693 .await;
1694
1695 assert!(result.is_ok());
1696 }
1697
1698 #[test]
1703 fn test_with_cache_enables_caching() {
1704 let config = McpConfig::new("test", "echo");
1705 let client = McpClient::new(config)
1706 .unwrap()
1707 .with_cache(CacheConfig::default());
1708
1709 assert!(client.cache.is_some());
1711 }
1712
1713 #[test]
1714 fn test_cache_stats_returns_none_when_disabled() {
1715 let client = McpClient::mock("test");
1716 assert!(client.cache_stats().is_none());
1717 }
1718
1719 #[test]
1720 fn test_cache_stats_returns_some_when_enabled() {
1721 let client = McpClient::mock("test").with_cache(CacheConfig::default());
1722 let stats = client.cache_stats();
1723 assert!(stats.is_some());
1724 let stats = stats.unwrap();
1725 assert_eq!(stats.entries, 0);
1726 assert_eq!(stats.hits, 0);
1727 assert_eq!(stats.misses, 0);
1728 }
1729
1730 #[tokio::test]
1731 async fn test_cache_hit_returns_cached_result() {
1732 let client = McpClient::mock("test").with_cache(CacheConfig::default());
1733
1734 let params = serde_json::json!({"entity": "qr-code"});
1735
1736 let result1 = client.call_tool("novanet_context", params.clone()).await;
1738 assert!(result1.is_ok());
1739
1740 let stats = client.cache_stats().unwrap();
1741 assert_eq!(stats.misses, 1);
1742 assert_eq!(stats.hits, 0);
1743 assert_eq!(stats.entries, 1);
1744
1745 let result2 = client.call_tool("novanet_context", params.clone()).await;
1747 assert!(result2.is_ok());
1748
1749 let stats = client.cache_stats().unwrap();
1750 assert_eq!(stats.misses, 1);
1751 assert_eq!(stats.hits, 1);
1752
1753 let r1 = result1.unwrap();
1755 let r2 = result2.unwrap();
1756 assert_eq!(r1.content.len(), r2.content.len());
1757 }
1758
1759 #[tokio::test]
1760 async fn test_cache_different_params_miss() {
1761 let client = McpClient::mock("test").with_cache(CacheConfig::default());
1762
1763 let params_a = serde_json::json!({"focus_key": "qr-code"});
1765 client.call_tool("novanet_context", params_a).await.unwrap();
1766
1767 let params_b = serde_json::json!({"focus_key": "barcode"});
1769 client.call_tool("novanet_context", params_b).await.unwrap();
1770
1771 let stats = client.cache_stats().unwrap();
1772 assert_eq!(stats.misses, 2);
1773 assert_eq!(stats.hits, 0);
1774 assert_eq!(stats.entries, 2);
1775 }
1776
1777 #[tokio::test]
1778 async fn test_cache_different_tools_miss() {
1779 let client = McpClient::mock("test").with_cache(CacheConfig::default());
1780
1781 let params = serde_json::json!({});
1782
1783 client
1785 .call_tool("novanet_describe", params.clone())
1786 .await
1787 .unwrap();
1788
1789 client
1791 .call_tool("novanet_search", params.clone())
1792 .await
1793 .unwrap();
1794
1795 let stats = client.cache_stats().unwrap();
1796 assert_eq!(stats.misses, 2);
1797 assert_eq!(stats.hits, 0);
1798 }
1799
1800 #[tokio::test]
1801 async fn test_cache_ttl_expiration() {
1802 use std::time::Duration;
1803
1804 let client = McpClient::mock("test").with_cache(CacheConfig {
1806 ttl: Duration::from_millis(50),
1807 max_entries: 100,
1808 });
1809
1810 let params = serde_json::json!({"test": true});
1811
1812 client.call_tool("test_tool", params.clone()).await.unwrap();
1814 assert_eq!(client.cache_stats().unwrap().entries, 1);
1815
1816 tokio::time::sleep(Duration::from_millis(60)).await;
1818
1819 client.call_tool("test_tool", params.clone()).await.unwrap();
1821
1822 let stats = client.cache_stats().unwrap();
1823 assert_eq!(stats.misses, 2); assert_eq!(stats.hits, 0);
1825 }
1826
1827 #[test]
1828 fn test_cache_hit_rate_calculation() {
1829 let stats = super::ResponseCacheStats {
1830 entries: 10,
1831 hits: 80,
1832 misses: 20,
1833 };
1834 assert!((stats.hit_rate() - 0.8).abs() < 0.001);
1835 }
1836
1837 #[test]
1838 fn test_cache_hit_rate_zero_total() {
1839 let stats = super::ResponseCacheStats {
1840 entries: 0,
1841 hits: 0,
1842 misses: 0,
1843 };
1844 assert_eq!(stats.hit_rate(), 0.0);
1845 }
1846
1847 #[test]
1848 fn test_cache_key_deterministic() {
1849 let params = serde_json::json!({"entity": "qr-code", "locale": "fr-FR"});
1850
1851 let key1 = super::ResponseCache::cache_key("tool", ¶ms);
1852 let key2 = super::ResponseCache::cache_key("tool", ¶ms);
1853
1854 assert_eq!(key1, key2);
1855 }
1856
1857 #[test]
1858 fn test_cache_key_different_for_different_params() {
1859 let params1 = serde_json::json!({"entity": "qr-code"});
1860 let params2 = serde_json::json!({"entity": "barcode"});
1861
1862 let key1 = super::ResponseCache::cache_key("tool", ¶ms1);
1863 let key2 = super::ResponseCache::cache_key("tool", ¶ms2);
1864
1865 assert_ne!(key1, key2);
1866 }
1867
1868 #[test]
1869 fn test_cache_key_different_for_different_tools() {
1870 let params = serde_json::json!({"test": true});
1871
1872 let key1 = super::ResponseCache::cache_key("tool_a", ¶ms);
1873 let key2 = super::ResponseCache::cache_key("tool_b", ¶ms);
1874
1875 assert_ne!(key1, key2);
1876 }
1877
1878 #[tokio::test]
1883 async fn test_ping_mock_client_succeeds() {
1884 let client = McpClient::mock("test");
1885
1886 let result = client.ping().await;
1887 assert!(result.is_ok());
1888
1889 let ping = result.unwrap();
1890 assert_eq!(ping.server, "test");
1891 assert!(ping.was_connected);
1892 assert!(ping.tool_count > 0);
1893 assert!(ping.latency.as_millis() < 100);
1895 }
1896
1897 #[test]
1898 fn test_mcp_ping_error_types() {
1899 let start_failed = super::McpPingError::StartFailed {
1900 server: "novanet".to_string(),
1901 details: "command not found".to_string(),
1902 };
1903 assert!(start_failed.to_string().contains("failed to start"));
1904 assert!(!start_failed.suggestion().is_empty());
1905
1906 let timeout = super::McpPingError::Timeout {
1907 server: "slow-server".to_string(),
1908 timeout: std::time::Duration::from_secs(10),
1909 };
1910 assert!(timeout.to_string().contains("timed out"));
1911
1912 let refused = super::McpPingError::ConnectionRefused {
1913 server: "offline".to_string(),
1914 };
1915 assert!(refused.to_string().contains("refused"));
1916
1917 let server_err = super::McpPingError::ServerError {
1918 server: "broken".to_string(),
1919 details: "internal error".to_string(),
1920 };
1921 assert!(server_err.to_string().contains("error"));
1922 }
1923
1924 #[tokio::test]
1925 async fn test_ping_result_has_valid_fields() {
1926 let client = McpClient::mock("novanet");
1927
1928 let result = client.ping().await.unwrap();
1929
1930 assert_eq!(result.server, "novanet");
1932 assert!(result.tool_count >= 3); assert!(result.was_connected); }
1935
1936 #[test]
1937 fn test_is_configured_returns_true_for_mock() {
1938 let client = McpClient::mock("test");
1939 assert!(client.is_configured());
1940 }
1941
1942 #[test]
1943 fn test_is_configured_returns_true_for_real_client() {
1944 let config = McpConfig::new("test", "echo");
1945 let client = McpClient::new(config).unwrap();
1946 assert!(client.is_configured());
1947 }
1948
1949 #[tokio::test]
1954 async fn test_call_tool_with_retry_events_mock_success() {
1955 use nika_event::EventLog;
1956
1957 let client = McpClient::mock("novanet");
1958 let event_log = EventLog::new();
1959 let task_id: Arc<str> = Arc::from("test_retry_events");
1960
1961 let result = client
1963 .call_tool_with_retry_events(
1964 "novanet_context",
1965 serde_json::json!({"focus_key": "qr-code"}),
1966 &task_id,
1967 &event_log,
1968 )
1969 .await;
1970
1971 assert!(
1972 result.is_ok(),
1973 "Mock call should succeed: {:?}",
1974 result.err()
1975 );
1976
1977 let events = event_log.filter_task("test_retry_events");
1979 let retry_events: Vec<_> = events
1980 .iter()
1981 .filter(|e| matches!(e.kind, EventKind::McpRetry { .. }))
1982 .collect();
1983 assert!(
1984 retry_events.is_empty(),
1985 "No retry events for successful calls"
1986 );
1987 }
1988
1989 #[tokio::test]
1990 async fn test_call_tool_with_retry_events_uses_cache() {
1991 use nika_event::EventLog;
1992 use std::time::Duration;
1993
1994 let client = McpClient::mock("novanet").with_cache(CacheConfig {
1996 ttl: Duration::from_secs(60),
1997 max_entries: 100,
1998 });
1999 let event_log = EventLog::new();
2000 let task_id: Arc<str> = Arc::from("test_cache_hit");
2001
2002 let params = serde_json::json!({"focus_key": "qr-code"});
2003
2004 let _result1 = client
2006 .call_tool_with_retry_events("novanet_context", params.clone(), &task_id, &event_log)
2007 .await
2008 .unwrap();
2009 assert!(!client.was_last_call_cached());
2010
2011 let _result2 = client
2013 .call_tool_with_retry_events("novanet_context", params.clone(), &task_id, &event_log)
2014 .await
2015 .unwrap();
2016 assert!(client.was_last_call_cached());
2017 }
2018
2019 #[tokio::test]
2020 async fn test_call_tool_with_retry_events_not_connected_fails() {
2021 use nika_event::EventLog;
2022
2023 let config = McpConfig::new("test", "nonexistent_command");
2025 let client = McpClient::new(config).unwrap();
2026 let event_log = EventLog::new();
2027 let task_id: Arc<str> = Arc::from("test_not_connected");
2028
2029 let result = client
2030 .call_tool_with_retry_events("some_tool", serde_json::json!({}), &task_id, &event_log)
2031 .await;
2032
2033 assert!(result.is_err());
2034 match result.unwrap_err() {
2035 McpError::McpNotConnected { .. } => {} err => panic!("Expected McpNotConnected, got: {err:?}"),
2037 }
2038 }
2039
2040 #[tokio::test]
2045 async fn test_disconnect_clears_response_cache() {
2046 let client = McpClient::mock("test_cache");
2047
2048 assert!(client.is_connected());
2050 client.disconnect().await.unwrap();
2051 assert!(!client.is_connected());
2052 }
2053
2054 #[tokio::test]
2055 async fn test_disconnect_clears_response_cache_with_entries() {
2056 let cache_config = CacheConfig {
2057 ttl: std::time::Duration::from_secs(300),
2058 max_entries: 100,
2059 };
2060 let client = McpClient::mock("test_cache_entries").with_cache(cache_config);
2061
2062 let _ = client
2064 .call_tool("novanet_describe", serde_json::json!({}))
2065 .await;
2066
2067 let stats = client.cache_stats();
2069 assert!(stats.is_some());
2070
2071 client.disconnect().await.unwrap();
2073 assert!(!client.is_connected());
2074 }
2075
2076 #[tokio::test]
2077 async fn test_disconnect_invalidates_tool_cache_via_adapter() {
2078 let config = McpConfig::new("test_adapter_cache", "echo");
2081 let client = McpClient::new(config).unwrap();
2082
2083 client.disconnect().await.unwrap();
2085 assert!(!client.is_connected());
2086 }
2087
2088 #[test]
2096 fn wave2_cache_key_canonical_json_ordering() {
2097 use serde_json::json;
2098
2099 let mut map_a = serde_json::Map::new();
2103 map_a.insert("alpha".to_string(), json!("first"));
2104 map_a.insert("beta".to_string(), json!("second"));
2105 map_a.insert("gamma".to_string(), json!("third"));
2106
2107 let mut map_b = serde_json::Map::new();
2108 map_b.insert("gamma".to_string(), json!("third"));
2109 map_b.insert("alpha".to_string(), json!("first"));
2110 map_b.insert("beta".to_string(), json!("second"));
2111
2112 let value_a = Value::Object(map_a);
2113 let value_b = Value::Object(map_b);
2114
2115 let json_a = serde_json::to_string(&value_a).unwrap();
2118 let json_b = serde_json::to_string(&value_b).unwrap();
2119
2120 let key_a = ResponseCache::cache_key("test_tool", &value_a);
2122 let key_b = ResponseCache::cache_key("test_tool", &value_b);
2123
2124 assert_eq!(
2128 key_a, key_b,
2129 "Canonical cache keys should match regardless of key insertion order. \
2130 json_a='{}', json_b='{}'",
2131 json_a, json_b
2132 );
2133 }
2134
2135 #[test]
2143 fn wave2_evict_oldest_collects_all_entries() {
2144 use std::time::Duration;
2145
2146 let cache = ResponseCache::new(CacheConfig {
2148 ttl: Duration::from_secs(300),
2149 max_entries: 5,
2150 });
2151
2152 for i in 0..6 {
2154 let params = serde_json::json!({"i": i});
2155 cache.put(
2156 &format!("tool_{}", i),
2157 ¶ms,
2158 ToolCallResult::success(vec![ContentBlock::text(format!("result_{}", i))]),
2159 );
2160 }
2161
2162 let stats = cache.stats();
2164 assert!(stats.entries <= 6, "Cache should have at most 6 entries");
2165
2166 let to_remove = 5 / 10; let actual_remove = to_remove.max(1); assert_eq!(
2180 actual_remove, 1,
2181 "Eviction removes max(max_entries/10, 1) entries. \
2182 BUG: This requires iterating ALL entries + sorting to find the oldest one. \
2183 An LRU cache would do this in O(1)."
2184 );
2185 }
2186
2187 #[test]
2192 fn test_is_connection_error_broken_pipe() {
2193 let err = McpError::McpToolError {
2194 tool: "test".into(),
2195 reason: "Broken pipe".into(),
2196 error_code: None,
2197 };
2198 assert!(McpClient::is_connection_error(&err));
2199 }
2200
2201 #[test]
2202 fn test_is_connection_error_connection_reset() {
2203 let err = McpError::McpToolError {
2204 tool: "test".into(),
2205 reason: "Connection reset by peer".into(),
2206 error_code: None,
2207 };
2208 assert!(McpClient::is_connection_error(&err));
2209 }
2210
2211 #[test]
2212 fn test_is_connection_error_connection_refused() {
2213 let err = McpError::McpToolError {
2214 tool: "test".into(),
2215 reason: "Connection refused".into(),
2216 error_code: None,
2217 };
2218 assert!(McpClient::is_connection_error(&err));
2219 }
2220
2221 #[test]
2222 fn test_is_connection_error_eof() {
2223 let err = McpError::McpToolError {
2224 tool: "test".into(),
2225 reason: "unexpected EOF".into(),
2226 error_code: None,
2227 };
2228 assert!(McpClient::is_connection_error(&err));
2229 }
2230
2231 #[test]
2232 fn test_is_connection_error_stdin_not_available() {
2233 let err = McpError::McpToolError {
2234 tool: "test".into(),
2235 reason: "stdin not available".into(),
2236 error_code: None,
2237 };
2238 assert!(McpClient::is_connection_error(&err));
2239 }
2240
2241 #[test]
2242 fn test_is_connection_error_stdout_not_available() {
2243 let err = McpError::McpToolError {
2244 tool: "test".into(),
2245 reason: "stdout not available".into(),
2246 error_code: None,
2247 };
2248 assert!(McpClient::is_connection_error(&err));
2249 }
2250
2251 #[test]
2252 fn test_is_connection_error_transport_closed() {
2253 let err = McpError::McpToolError {
2254 tool: "test".into(),
2255 reason: "Transport closed unexpectedly".into(),
2256 error_code: None,
2257 };
2258 assert!(McpClient::is_connection_error(&err));
2259 }
2260
2261 #[test]
2262 fn test_is_connection_error_transport_send() {
2263 let err = McpError::McpToolError {
2264 tool: "test".into(),
2265 reason: "Transport send failed".into(),
2266 error_code: None,
2267 };
2268 assert!(McpClient::is_connection_error(&err));
2269 }
2270
2271 #[test]
2272 fn test_is_connection_error_non_connection_error() {
2273 let err = McpError::McpToolError {
2274 tool: "test".into(),
2275 reason: "invalid parameter 'mode'".into(),
2276 error_code: None,
2277 };
2278 assert!(!McpClient::is_connection_error(&err));
2279 }
2280
2281 #[test]
2282 fn test_is_connection_error_not_connected() {
2283 let err = McpError::McpNotConnected {
2284 name: "novanet".into(),
2285 };
2286 assert!(!McpClient::is_connection_error(&err));
2288 }
2289}