Skip to main content

nika_mcp/
client.rs

1//! MCP Client Implementation
2//!
3//! Provides a client for connecting to MCP (Model Context Protocol) servers.
4//! Uses rmcp SDK for real connections, with mock mode for testing.
5//!
6//! ## Usage
7//!
8//! ```rust,ignore
9//! use nika_mcp::{McpClient, McpConfig};
10//! use serde_json::json;
11//!
12//! // Create client from config
13//! let config = McpConfig::new("novanet", "npx")
14//!     .with_args(["-y", "@novanet/mcp-server"]);
15//! let client = McpClient::new(config)?;
16//!
17//! // Connect and call tool
18//! client.connect().await?;
19//! let result = client.call_tool("novanet_describe", json!({})).await?;
20//! ```
21//!
22//! ## Mock Mode
23//!
24//! For testing, use `McpClient::mock()` to create a pre-connected client
25//! that returns canned responses:
26//!
27//! ```rust,ignore
28//! let client = McpClient::mock("novanet");
29//! assert!(client.is_connected());
30//! ```
31//!
32//! ## Response Caching
33//!
34//! Enable response caching for deterministic tools:
35//!
36//! ```rust,ignore
37//! use std::time::Duration;
38//!
39//! let client = McpClient::new(config)?
40//!     .with_cache(CacheConfig {
41//!         ttl: Duration::from_secs(300), // 5 minutes
42//!         max_entries: 1000,
43//!     });
44//!
45//! // First call hits the server
46//! let r1 = client.call_tool("novanet_describe", json!({})).await?;
47//!
48//! // Second call with same params returns cached result
49//! let r2 = client.call_tool("novanet_describe", json!({})).await?;
50//! ```
51
52use 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// ═══════════════════════════════════════════════════════════════════════════
70// HEALTH CHECK TYPES
71// ═══════════════════════════════════════════════════════════════════════════
72
73/// Result of a successful MCP server ping.
74#[derive(Debug, Clone)]
75pub struct McpPingResult {
76    /// Server name
77    pub server: String,
78
79    /// Round-trip latency
80    pub latency: Duration,
81
82    /// Number of tools available on the server
83    pub tool_count: usize,
84
85    /// Whether the connection was already established
86    pub was_connected: bool,
87}
88
89/// Error when pinging an MCP server.
90#[derive(Debug, Clone)]
91pub enum McpPingError {
92    /// Server process failed to start
93    StartFailed { server: String, details: String },
94
95    /// Server timed out responding
96    Timeout { server: String, timeout: Duration },
97
98    /// Connection was refused
99    ConnectionRefused { server: String },
100
101    /// Server responded with error
102    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    /// Get a user-friendly suggestion for fixing this error.
126    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// ═══════════════════════════════════════════════════════════════════════════
143// CACHE TYPES
144// ═══════════════════════════════════════════════════════════════════════════
145
146/// Cache configuration for MCP response caching.
147///
148/// # Example
149///
150/// ```rust,ignore
151/// use std::time::Duration;
152///
153/// let config = CacheConfig {
154///     ttl: Duration::from_secs(300), // 5 minutes
155///     max_entries: 1000,
156/// };
157/// ```
158#[derive(Debug, Clone)]
159pub struct CacheConfig {
160    /// Time-to-live for cache entries
161    pub ttl: Duration,
162
163    /// Maximum number of entries in the cache
164    pub max_entries: usize,
165}
166
167impl Default for CacheConfig {
168    fn default() -> Self {
169        Self {
170            ttl: Duration::from_secs(300), // 5 minutes
171            max_entries: 1000,
172        }
173    }
174}
175
176/// A cached MCP tool response.
177///
178/// Stores result behind `Arc` for cheap cloning on cache hits.
179/// `ToolCallResult` can contain large content blocks (text, base64 images),
180/// so Arc avoids deep cloning the entire content on every cache access.
181#[derive(Debug, Clone)]
182struct CacheEntry {
183    /// The cached result (Arc for cheap cloning)
184    result: Arc<ToolCallResult>,
185
186    /// When the entry was created
187    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/// Response cache for MCP tool calls.
204///
205/// Thread-safe cache using DashMap with TTL-based expiration.
206#[derive(Debug)]
207struct ResponseCache {
208    /// Configuration
209    config: CacheConfig,
210
211    /// Cache entries keyed by "tool:params_hash"
212    entries: DashMap<String, CacheEntry, rustc_hash::FxBuildHasher>,
213
214    /// Cache hit counter
215    hits: AtomicU64,
216
217    /// Cache miss counter
218    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    /// Generate cache key from tool name and params.
232    ///
233    /// Uses canonical JSON serialization (sorted keys) so that semantically
234    /// identical objects with different key insertion order produce the same key.
235    fn cache_key(tool: &str, params: &Value) -> String {
236        let mut hasher = FxHasher::default();
237        // Canonicalize: sort object keys recursively, then serialize.
238        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    /// Maximum nesting depth for JSON canonicalization to prevent stack overflow.
255    const MAX_CANONICALIZE_DEPTH: usize = 128;
256
257    /// Recursively sort all object keys in a JSON Value for canonical serialization.
258    ///
259    /// Limits recursion depth to [`MAX_CANONICALIZE_DEPTH`] to prevent stack overflow
260    /// on adversarial input.
261    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    /// Get a cached result if it exists and is not expired.
292    ///
293    /// Returns an `Arc<ToolCallResult>` for cheap sharing (atomic ref-count increment
294    /// instead of deep cloning content blocks).
295    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                // Entry expired, remove it
301                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    /// Store a result in the cache.
316    ///
317    /// Wraps the result in `Arc` for cheap retrieval on subsequent hits.
318    fn put(&self, tool: &str, params: &Value, result: ToolCallResult) {
319        // Don't cache errors
320        if result.is_error {
321            return;
322        }
323
324        let key = Self::cache_key(tool, params);
325
326        // Evict oldest entries if over capacity
327        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    /// Evict the oldest entries to make room for new ones.
335    ///
336    /// Uses partial sort (`select_nth_unstable_by_key`) for O(n) eviction
337    /// instead of O(n log n) full sort.
338    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            // Fewer entries than eviction target — remove all
348            for (key, _) in &entries {
349                self.entries.remove(key);
350            }
351            return;
352        }
353
354        // Partial sort: partition so the `to_remove` oldest are at the front
355        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    /// Clear all entries.
363    fn clear(&self) {
364        self.entries.clear();
365        self.hits.store(0, Ordering::Relaxed);
366        self.misses.store(0, Ordering::Relaxed);
367    }
368
369    /// Get cache statistics.
370    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/// Response cache statistics for observability.
380#[derive(Debug, Clone, Default)]
381pub struct ResponseCacheStats {
382    /// Number of entries in the cache
383    pub entries: usize,
384
385    /// Number of cache hits
386    pub hits: u64,
387
388    /// Number of cache misses
389    pub misses: u64,
390}
391
392impl ResponseCacheStats {
393    /// Calculate hit rate (0.0 to 1.0)
394    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
404/// MCP Client for connecting to and interacting with MCP servers.
405///
406/// The client can operate in two modes:
407/// - **Real mode**: Uses rmcp SDK via RmcpClientAdapter
408/// - **Mock mode**: Returns canned responses for testing
409///
410/// ## Validation
411///
412/// Enable parameter validation with `with_validation()`:
413///
414/// ```rust,ignore
415/// let client = McpClient::new(config)?
416///     .with_validation(ValidationConfig::default());
417/// ```
418///
419/// When validation is enabled:
420/// 1. `connect()` caches tool schemas from `list_tools()`
421/// 2. `call_tool()` validates params before calling the server
422/// 3. Errors are enhanced with required fields and suggestions
423pub struct McpClient {
424    /// Server name (from config or mock)
425    name: String,
426
427    /// Connection state (atomic for interior mutability)
428    /// For mock clients, this tracks mock state.
429    /// For real clients, rmcp adapter tracks actual connection.
430    connected: AtomicBool,
431
432    /// Whether this is a mock client
433    is_mock: bool,
434
435    /// rmcp adapter for real connections (None for mock clients)
436    adapter: Option<RmcpClientAdapter>,
437
438    /// Parameter validator (None if validation disabled)
439    validator: Option<McpValidator>,
440
441    /// Response cache (None if caching disabled)
442    cache: Option<ResponseCache>,
443
444    /// Whether the last call_tool() was a cache hit (for event logging)
445    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    /// Create a new MCP client from configuration.
464    ///
465    /// Validates the configuration and returns an error if invalid.
466    /// The client is created in disconnected state.
467    ///
468    /// # Errors
469    ///
470    /// Returns `McpError::ValidationError` if:
471    /// - `config.name` is empty
472    /// - `config.command` is empty
473    ///
474    /// # Example
475    ///
476    /// ```rust,ignore
477    /// let config = McpConfig::new("novanet", "npx")
478    ///     .with_args(["-y", "@novanet/mcp-server"]);
479    /// let client = McpClient::new(config)?;
480    /// assert!(!client.is_connected());
481    /// ```
482    pub fn new(config: McpConfig) -> Result<Self> {
483        // Validate configuration
484        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    /// Enable parameter validation with the given config.
511    ///
512    /// When validation is enabled:
513    /// - `connect()` will cache tool schemas from `list_tools()`
514    /// - `call_tool()` will validate params before calling the server
515    /// - Errors will be enhanced with required fields and suggestions
516    ///
517    /// # Example
518    ///
519    /// ```rust,ignore
520    /// let client = McpClient::new(config)?
521    ///     .with_validation(ValidationConfig::default());
522    /// ```
523    pub fn with_validation(mut self, config: ValidationConfig) -> Self {
524        self.validator = Some(McpValidator::new(config));
525        self
526    }
527
528    /// Enable response caching with the given config.
529    ///
530    /// When caching is enabled:
531    /// - Successful tool responses are cached by `tool:params_hash` key
532    /// - Subsequent calls with same params return cached results
533    /// - Cache entries expire after TTL
534    /// - Error responses are never cached
535    ///
536    /// # Example
537    ///
538    /// ```rust,ignore
539    /// use std::time::Duration;
540    ///
541    /// let client = McpClient::new(config)?
542    ///     .with_cache(CacheConfig {
543    ///         ttl: Duration::from_secs(300), // 5 minutes
544    ///         max_entries: 1000,
545    ///     });
546    /// ```
547    pub fn with_cache(mut self, config: CacheConfig) -> Self {
548        self.cache = Some(ResponseCache::new(config));
549        self
550    }
551
552    /// Get cache statistics (hits, misses, entries).
553    ///
554    /// Returns `None` if caching is disabled.
555    pub fn cache_stats(&self) -> Option<ResponseCacheStats> {
556        self.cache.as_ref().map(|c| c.stats())
557    }
558
559    /// Check if the last `call_tool()` invocation was served from cache.
560    ///
561    /// This method returns the cached status from the most recent tool call.
562    /// Use this after `call_tool()` to determine if the response was cached.
563    pub fn was_last_call_cached(&self) -> bool {
564        self.last_cache_hit.load(Ordering::SeqCst)
565    }
566
567    /// Create a mock MCP client for testing.
568    ///
569    /// The mock client is pre-connected and returns canned responses:
570    /// - `novanet_describe`: Returns `{"nodes": 62, "arcs": 182}`
571    /// - `novanet_context`: Returns entity context JSON
572    /// - Other tools: Returns a generic success response
573    ///
574    /// # Example
575    ///
576    /// ```rust,ignore
577    /// let client = McpClient::mock("novanet");
578    /// assert!(client.is_connected());
579    /// ```
580    pub fn mock(name: &str) -> Self {
581        Self {
582            name: name.to_string(),
583            connected: AtomicBool::new(true), // Mock is pre-connected
584            is_mock: true,
585            adapter: None,
586            validator: None,
587            cache: None,
588            last_cache_hit: AtomicBool::new(false),
589        }
590    }
591
592    /// Get the server name.
593    pub fn name(&self) -> &str {
594        &self.name
595    }
596
597    /// Check if the client is connected to the server.
598    ///
599    /// For real clients, delegates to adapter's sync check (non-blocking).
600    /// This avoids race conditions where AtomicBool becomes stale.
601    pub fn is_connected(&self) -> bool {
602        if self.is_mock {
603            return self.connected.load(Ordering::SeqCst);
604        }
605        // Delegate to adapter for accurate state (avoids stale AtomicBool)
606        self.adapter
607            .as_ref()
608            .map(|a| a.is_connected_sync())
609            .unwrap_or(false)
610    }
611
612    /// Check connection state asynchronously (accurate for real clients).
613    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    /// Ping the MCP server to verify it's responsive.
625    ///
626    /// This method:
627    /// 1. Connects to the server if not already connected
628    /// 2. Calls `list_tools()` to verify the server responds
629    /// 3. Returns latency and tool count
630    ///
631    /// # Example
632    ///
633    /// ```rust,ignore
634    /// let result = client.ping().await?;
635    /// println!("Server {} responded in {:?} with {} tools",
636    ///     result.server, result.latency, result.tool_count);
637    /// ```
638    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        // For mock clients, always succeed quickly
643        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        // Connect if needed
653        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        // Call list_tools with timeout to verify server responds
669        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    /// Quick check if MCP server is likely to be reachable.
688    ///
689    /// Returns true if:
690    /// - Mock client: always true
691    /// - Real client: adapter exists and is configured
692    ///
693    /// This is a synchronous check that doesn't actually connect.
694    /// Use `ping()` for a full health check.
695    pub fn is_configured(&self) -> bool {
696        self.is_mock || self.adapter.is_some()
697    }
698
699    /// Connect to the MCP server.
700    ///
701    /// For mock clients, this is a no-op that always succeeds.
702    /// For real clients, this uses rmcp SDK to connect.
703    ///
704    /// When validation is enabled, this also caches tool schemas from `list_tools()`.
705    ///
706    /// This method is idempotent - calling it when already connected succeeds.
707    ///
708    /// # Errors
709    ///
710    /// Returns `McpError::McpStartError` if the server process fails to start.
711    /// Returns `McpError::McpSchemaError` if schema caching fails.
712    pub async fn connect(&self) -> Result<()> {
713        if self.is_mock {
714            self.connected.store(true, Ordering::SeqCst);
715            // Populate mock tools if validator is enabled
716            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        // Populate schema cache if validator is enabled
740        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    /// Disconnect from the MCP server.
760    ///
761    /// For mock clients, this just updates the connection state.
762    /// For real clients, this terminates the server process via rmcp.
763    ///
764    /// This method is idempotent - calling it when already disconnected succeeds.
765    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        // Clear response cache — stale after disconnect
776        if let Some(ref cache) = self.cache {
777            cache.clear();
778        }
779
780        // Clear schema cache — schemas may change after server restart
781        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    /// Reconnect to the MCP server.
790    ///
791    /// Useful when the connection is broken (e.g., broken pipe, server crashed).
792    /// This terminates any existing connection and establishes a new one.
793    ///
794    /// # Errors
795    ///
796    /// Returns `McpError::McpStartError` if reconnection fails.
797    ///
798    /// # Example
799    ///
800    /// ```rust,ignore
801    /// // After detecting a broken connection
802    /// client.reconnect().await?;
803    /// // Retry the failed operation
804    /// ```
805    pub async fn reconnect(&self) -> Result<()> {
806        if self.is_mock {
807            self.connected.store(true, Ordering::SeqCst);
808            return Ok(());
809        }
810
811        // Disconnect clears validator cache + response cache
812        self.disconnect().await?;
813
814        // Reconnect via the adapter (re-establishes transport)
815        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    /// Check if an error indicates a broken connection.
828    ///
829    /// Used to determine if a reconnection attempt should be made
830    /// before the next retry.
831    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    /// Enhance an error with validation context if available.
844    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    /// Call an MCP tool with the given parameters.
855    ///
856    /// # Arguments
857    ///
858    /// * `name` - Tool name (e.g., "novanet_context", "read_file")
859    /// * `params` - Tool parameters as JSON value
860    ///
861    /// # Validation
862    ///
863    /// When validation is enabled via `with_validation()`:
864    /// - Parameters are validated against the tool schema before calling
865    /// - Errors include required fields and suggestions
866    ///
867    /// # Errors
868    ///
869    /// Returns `McpError::McpValidationFailed` if parameter validation fails.
870    /// Returns `McpError::McpNotConnected` if the client is not connected.
871    /// Returns `McpError::McpToolError` if the tool call fails.
872    ///
873    /// # Example
874    ///
875    /// ```rust,ignore
876    /// let result = client.call_tool("novanet_context", json!({
877    ///     "mode": "page",
878    ///     "focus_key": "qr-code",
879    ///     "locale": "fr-FR"
880    /// })).await?;
881    /// ```
882    pub async fn call_tool(&self, name: &str, params: Value) -> Result<ToolCallResult> {
883        // Pre-call validation (if enabled)
884        if let Some(ref validator) = self.validator {
885            if validator.config().pre_validate {
886                let result = validator.validate(&self.name, name, &params);
887                if !result.is_valid {
888                    // Convert validation errors to McpError
889                    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        // Check cache for a hit (before making the actual call)
932        if let Some(ref cache) = self.cache {
933            if let Some(cached_result) = cache.get(name, &params) {
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        // Not a cache hit - mark as miss
945        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, &params);
954            // Store mock result in cache too
955            if let Some(ref cache) = self.cache {
956                cache.put(name, &params, result.clone());
957            }
958            return Ok(result);
959        }
960
961        // Real mode: use rmcp adapter with retry via backon (NIKA-103)
962        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                        // On connection errors, attempt reconnect for next retry
977                        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        // Store successful result in cache
1000        if let Some(ref cache) = self.cache {
1001            cache.put(name, &params, 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    /// Call an MCP tool with retry event emission.
1012    ///
1013    /// This method is similar to `call_tool()` but emits `McpRetry` events
1014    /// through the provided EventLog when connection errors trigger retries.
1015    /// This enables TUI observability of MCP retry attempts.
1016    ///
1017    /// # Arguments
1018    ///
1019    /// * `name` - Tool name (e.g., "novanet_context")
1020    /// * `params` - Tool parameters as JSON
1021    /// * `task_id` - Task ID for event correlation
1022    /// * `event_log` - EventLog for emitting McpRetry events
1023    ///
1024    /// # Example
1025    ///
1026    /// ```rust,ignore
1027    /// let result = client.call_tool_with_retry_events(
1028    ///     "novanet_context",
1029    ///     json!({"mode": "page", "locale": "fr-FR"}),
1030    ///     &task_id,
1031    ///     &event_log,
1032    /// ).await?;
1033    /// ```
1034    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        // Pre-call validation (if enabled) - same as call_tool()
1042        if let Some(ref validator) = self.validator {
1043            if validator.config().pre_validate {
1044                let result = validator.validate(&self.name, name, &params);
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        // Check cache for a hit
1089        if let Some(ref cache) = self.cache {
1090            if let Some(cached_result) = cache.get(name, &params) {
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, &params);
1110            if let Some(ref cache) = self.cache {
1111                cache.put(name, &params, result.clone());
1112            }
1113            return Ok(result);
1114        }
1115
1116        // Real mode: use rmcp adapter with retry via backon + event emission (NIKA-103)
1117        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; // total = initial + retries
1126        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                            // Emit McpRetry event for TUI observability
1138                            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        // Store successful result in cache
1169        if let Some(ref cache) = self.cache {
1170            cache.put(name, &params, 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    /// Read a resource from the MCP server.
1181    ///
1182    /// # Arguments
1183    ///
1184    /// * `uri` - Resource URI (e.g., "file:///path", "neo4j://entity/qr-code")
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns `McpError::McpNotConnected` if the client is not connected.
1189    /// Returns `McpError::McpResourceNotFound` if the resource doesn't exist.
1190    ///
1191    /// # Example
1192    ///
1193    /// ```rust,ignore
1194    /// let resource = client.read_resource("neo4j://entity/qr-code").await?;
1195    /// ```
1196    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        // Real mode: use rmcp adapter with retry via backon (NIKA-103)
1207        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                        // On connection errors, attempt reconnect for next retry
1220                        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    /// List all available tools from the MCP server.
1244    ///
1245    /// # Errors
1246    ///
1247    /// Returns `McpError::McpNotConnected` if the client is not connected.
1248    ///
1249    /// # Example
1250    ///
1251    /// ```rust,ignore
1252    /// let tools = client.list_tools().await?;
1253    /// for tool in tools {
1254    ///     println!("Tool: {}", tool.name);
1255    /// }
1256    /// ```
1257    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        // Real mode: use rmcp adapter
1268        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    // ═══════════════════════════════════════════════════════════════
1279    // MOCK IMPLEMENTATIONS
1280    // ═══════════════════════════════════════════════════════════════
1281
1282    /// Generate mock response for tool calls.
1283    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                // Extract focus_key/entity from params for a realistic response
1297                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                // Generic success response for unknown tools
1321                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    /// Generate mock response for resource reads.
1332    fn mock_read_resource(&self, uri: &str) -> ResourceContent {
1333        // Generate a mock resource based on URI pattern
1334        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    /// Get tool definitions synchronously.
1361    ///
1362    /// For mock clients, returns mock tool definitions.
1363    /// For real clients, returns cached tools from the last `list_tools()` call.
1364    ///
1365    /// **Important:** For real clients, you must call `list_tools().await` first
1366    /// to populate the cache before this method returns useful results.
1367    ///
1368    /// This method is primarily used for building rig agents where we need
1369    /// tool definitions during construction.
1370    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    /// Check if the tool cache is still fresh within the given TTL.
1381    ///
1382    /// Returns `true` for mock clients (always fresh).
1383    /// For real clients, checks if tools were fetched within `ttl` duration.
1384    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    /// Invalidate the tool cache, forcing re-fetch on next `list_tools()` call.
1395    ///
1396    /// No-op for mock clients.
1397    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    /// Generate mock tool definitions.
1406    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// Drop is handled by RmcpClientAdapter which cleans up the child process
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432
1433    // ═══════════════════════════════════════════════════════════════
1434    // CONCURRENT CALL TESTS
1435    // ═══════════════════════════════════════════════════════════════
1436
1437    #[tokio::test]
1438    async fn test_multiple_sequential_calls() {
1439        // Verify multiple sequential calls work
1440        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        // Verify concurrent calls work
1458        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    // ═══════════════════════════════════════════════════════════════
1478    // BASIC TESTS
1479    // ═══════════════════════════════════════════════════════════════
1480
1481    #[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    // ═══════════════════════════════════════════════════════════════
1514    // RESOURCE READ TESTS
1515    // ═══════════════════════════════════════════════════════════════
1516
1517    #[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    // ═══════════════════════════════════════════════════════════════
1539    // DROP TESTS
1540    // ═══════════════════════════════════════════════════════════════
1541
1542    #[test]
1543    fn test_mock_client_drop_is_noop() {
1544        // Mock clients should not try to kill any process
1545        let client = McpClient::mock("test");
1546        assert!(client.is_mock);
1547        // Dropping should not panic
1548        drop(client);
1549    }
1550
1551    #[test]
1552    fn test_real_client_drop_without_process() {
1553        // Real client that was never connected should drop safely
1554        let config = McpConfig::new("test", "echo");
1555        let client = McpClient::new(config).unwrap();
1556        assert!(!client.is_mock);
1557        // No process was spawned, drop should be safe
1558        drop(client);
1559    }
1560
1561    // ═══════════════════════════════════════════════════════════════
1562    // VALIDATION TESTS
1563    // ═══════════════════════════════════════════════════════════════
1564
1565    #[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        // Should have validator
1573        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        // Connect should populate cache
1581        client.connect().await.unwrap();
1582
1583        // Cache should have mock tools
1584        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        // novanet_context requires "mode" and "locale"
1595        let result = client
1596            .call_tool(
1597                "novanet_context",
1598                serde_json::json!({
1599                    "focus_key": "qr-code"
1600                    // Missing "mode" and "locale"
1601                }),
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        // Valid params - has required "mode" and "locale"
1624        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, // Disabled
1642            ..Default::default()
1643        };
1644        let client = McpClient::mock("novanet").with_validation(config);
1645        client.connect().await.unwrap();
1646
1647        // Missing required field, but validation is disabled
1648        let result = client
1649            .call_tool(
1650                "novanet_context",
1651                serde_json::json!({
1652                    "focus_key": "qr-code"
1653                    // Missing "mode" and "locale" - but validation disabled
1654                }),
1655            )
1656            .await;
1657
1658        // Should pass because validation is disabled
1659        assert!(result.is_ok());
1660    }
1661
1662    #[tokio::test]
1663    async fn test_call_tool_without_validation_works() {
1664        // Client without validation
1665        let client = McpClient::mock("novanet");
1666
1667        // No connect needed for mock without validation
1668        let result = client
1669            .call_tool(
1670                "novanet_context",
1671                serde_json::json!({
1672                    // Missing required fields but no validator
1673                }),
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        // Unknown tool - no schema cached, should pass through
1686        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    // ═══════════════════════════════════════════════════════════════
1699    // RESPONSE CACHING TESTS
1700    // ═══════════════════════════════════════════════════════════════
1701
1702    #[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        // Should have cache
1710        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        // First call - miss
1737        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        // Second call with same params - hit
1746        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        // Results should be equivalent
1754        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        // Call with params A
1764        let params_a = serde_json::json!({"focus_key": "qr-code"});
1765        client.call_tool("novanet_context", params_a).await.unwrap();
1766
1767        // Call with params B - different, should miss
1768        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        // Call tool A
1784        client
1785            .call_tool("novanet_describe", params.clone())
1786            .await
1787            .unwrap();
1788
1789        // Call tool B with same params - different tool, should miss
1790        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        // Very short TTL for testing
1805        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        // First call - miss
1813        client.call_tool("test_tool", params.clone()).await.unwrap();
1814        assert_eq!(client.cache_stats().unwrap().entries, 1);
1815
1816        // Wait for TTL to expire
1817        tokio::time::sleep(Duration::from_millis(60)).await;
1818
1819        // Second call - should be a miss because entry expired
1820        client.call_tool("test_tool", params.clone()).await.unwrap();
1821
1822        let stats = client.cache_stats().unwrap();
1823        assert_eq!(stats.misses, 2); // Both calls were misses
1824        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", &params);
1852        let key2 = super::ResponseCache::cache_key("tool", &params);
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", &params1);
1863        let key2 = super::ResponseCache::cache_key("tool", &params2);
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", &params);
1873        let key2 = super::ResponseCache::cache_key("tool_b", &params);
1874
1875        assert_ne!(key1, key2);
1876    }
1877
1878    // ═══════════════════════════════════════════════════════════════
1879    // MCP PING HEALTH CHECK TESTS
1880    // ═══════════════════════════════════════════════════════════════
1881
1882    #[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        // Latency should be very small for mock
1894        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        // Check all fields are populated
1931        assert_eq!(result.server, "novanet");
1932        assert!(result.tool_count >= 3); // Mock has at least 3 tools
1933        assert!(result.was_connected); // Mock is pre-connected
1934    }
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    // ═══════════════════════════════════════════════════════════════
1950    // McpRetry Event Emission Tests
1951    // ═══════════════════════════════════════════════════════════════
1952
1953    #[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        // Mock client should succeed immediately (no retries)
1962        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        // No McpRetry events should be emitted for successful mock calls
1978        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        // Create client with cache enabled
1995        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        // First call - cache miss
2005        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        // Second call - should hit cache
2012        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        // Create a real (not mock) client that isn't connected
2024        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 { .. } => {} // Expected
2036            err => panic!("Expected McpNotConnected, got: {err:?}"),
2037        }
2038    }
2039
2040    // ═══════════════════════════════════════════════════════════════════════════
2041    // NIKA-104: Cache Invalidation on Disconnect
2042    // ═══════════════════════════════════════════════════════════════════════════
2043
2044    #[tokio::test]
2045    async fn test_disconnect_clears_response_cache() {
2046        let client = McpClient::mock("test_cache");
2047
2048        // Mock disconnect just flips connected flag
2049        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        // Populate cache via call_tool on mock
2063        let _ = client
2064            .call_tool("novanet_describe", serde_json::json!({}))
2065            .await;
2066
2067        // Verify cache has an entry
2068        let stats = client.cache_stats();
2069        assert!(stats.is_some());
2070
2071        // Disconnect should not crash even with cache entries
2072        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        // For real (non-mock) clients, disconnect delegates to adapter
2079        // which now calls invalidate_tool_cache()
2080        let config = McpConfig::new("test_adapter_cache", "echo");
2081        let client = McpClient::new(config).unwrap();
2082
2083        // Disconnect on non-connected real client is a no-op (idempotent)
2084        client.disconnect().await.unwrap();
2085        assert!(!client.is_connected());
2086    }
2087
2088    // ========================================================================
2089    // Wave 2: Deep Audit - Bug-Proving Tests
2090    // ========================================================================
2091
2092    // ---- FIXED: Cache key uses canonical JSON serialization ----
2093    // cache_key() now canonicalizes JSON (sorts keys recursively) before hashing,
2094    // so semantically identical objects always match regardless of insertion order.
2095    #[test]
2096    fn wave2_cache_key_canonical_json_ordering() {
2097        use serde_json::json;
2098
2099        // Build two semantically identical JSON objects with different key ordering.
2100        // serde_json::json! macro preserves source order, so we need to construct
2101        // maps manually with different insertion order.
2102        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        // Both represent the same logical JSON: {"alpha":"first","beta":"second","gamma":"third"}
2116        // but their serializations may differ due to insertion order.
2117        let json_a = serde_json::to_string(&value_a).unwrap();
2118        let json_b = serde_json::to_string(&value_b).unwrap();
2119
2120        // Now compute cache keys using the same algorithm as ResponseCache::cache_key
2121        let key_a = ResponseCache::cache_key("test_tool", &value_a);
2122        let key_b = ResponseCache::cache_key("test_tool", &value_b);
2123
2124        // FIXED: cache_key now canonicalizes JSON (sorts keys recursively),
2125        // so semantically identical objects always produce the same cache key
2126        // regardless of key insertion order or serde_json Map implementation.
2127        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    // ---- BUG: evict_oldest() is O(n log n) under contention ----
2136    // ResponseCache::evict_oldest() iterates ALL DashMap entries, collects into
2137    // a Vec, sorts by creation time, then removes the oldest 10%.
2138    // Under high concurrency with many cache entries, this is expensive.
2139    //
2140    // FIX: Use a bounded LRU cache (e.g., `moka` or `quick_cache`) instead of
2141    // DashMap + manual eviction, or maintain a sorted index.
2142    #[test]
2143    fn wave2_evict_oldest_collects_all_entries() {
2144        use std::time::Duration;
2145
2146        // Create a cache with small max_entries to trigger eviction
2147        let cache = ResponseCache::new(CacheConfig {
2148            ttl: Duration::from_secs(300),
2149            max_entries: 5,
2150        });
2151
2152        // Fill the cache beyond capacity
2153        for i in 0..6 {
2154            let params = serde_json::json!({"i": i});
2155            cache.put(
2156                &format!("tool_{}", i),
2157                &params,
2158                ToolCallResult::success(vec![ContentBlock::text(format!("result_{}", i))]),
2159            );
2160        }
2161
2162        // Verify cache has entries (some may have been evicted)
2163        let stats = cache.stats();
2164        assert!(stats.entries <= 6, "Cache should have at most 6 entries");
2165
2166        // The eviction strategy removes ~10% of max_entries = 0.5, rounded up to 1.
2167        // After inserting 6 items into a cache with max_entries=5,
2168        // evict_oldest should have been triggered on the 6th insertion.
2169        //
2170        // BUG (performance): evict_oldest collects ALL entries into a Vec,
2171        // sorts them, then removes the oldest. For a 5-entry cache this is fine.
2172        // For 100k entries under concurrent access, this is O(n log n) while
2173        // holding DashMap read locks on every shard.
2174        //
2175        // We can't directly measure the perf impact in a unit test,
2176        // but we CAN verify the eviction strategy and document the issue.
2177        let to_remove = 5 / 10; // max_entries / 10 = 0
2178        let actual_remove = to_remove.max(1); // .max(1) = 1
2179        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    // ═══════════════════════════════════════════════════════════════
2188    // CONNECTION ERROR DETECTION TESTS
2189    // ═══════════════════════════════════════════════════════════════
2190
2191    #[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        // McpNotConnected message doesn't contain transport keywords
2287        assert!(!McpClient::is_connection_error(&err));
2288    }
2289}